diff --git a/backend/scripts/seed-ssa-intent-prompt.ts b/backend/scripts/seed-ssa-intent-prompt.ts index 31c7d790..c81de2f8 100644 --- a/backend/scripts/seed-ssa-intent-prompt.ts +++ b/backend/scripts/seed-ssa-intent-prompt.ts @@ -79,7 +79,15 @@ const SSA_INTENT_PROMPT = `你是一个临床统计分析意图理解引擎。 {"goal":"regression","outcome_var":"Death","outcome_type":"binary","predictor_vars":["Age","BMI","Smoking","Stage"],"predictor_types":["continuous","continuous","binary","categorical"],"grouping_var":null,"design":"independent","confidence":0.8,"reasoning":"用户想分析影响死亡率的因素,Death是二分类结局,其余变量作为预测因素纳入logistic回归"} \`\`\` -### 示例 4:模糊表达 — 需要追问 +### 示例 4:统计学意义/检验 +用户: "Yqol和bmi是否有统计学意义?" +数据画像中有: Yqol [numeric], bmi [numeric], sex [categorical], age [numeric] +输出: +\`\`\`json +{"goal":"correlation","outcome_var":"Yqol","outcome_type":"continuous","predictor_vars":["bmi"],"predictor_types":["continuous"],"grouping_var":null,"design":"independent","confidence":0.85,"reasoning":"用户想了解Yqol和bmi之间是否存在统计学显著关系,两者都是连续变量,适合相关分析或回归分析"} +\`\`\` + +### 示例 5:模糊表达 — 需要追问 用户: "帮我分析一下这份数据" 数据画像中有: 10个变量 输出: @@ -87,7 +95,7 @@ const SSA_INTENT_PROMPT = `你是一个临床统计分析意图理解引擎。 {"goal":"descriptive","outcome_var":null,"outcome_type":null,"predictor_vars":[],"predictor_types":[],"grouping_var":null,"design":"independent","confidence":0.35,"reasoning":"用户没有指定任何分析目标和变量,只能先做描述性统计,建议追问具体分析目的"} \`\`\` -### 示例 5:队列研究 +### 示例 6:队列研究 用户: "我想做一个完整的队列研究分析,看看新药对预后的影响" 数据画像中有: Drug [categorical, 2个水平], Outcome [categorical, 2个水平: 0/1], Age [numeric], Gender [categorical], BMI [numeric], Comorbidity [categorical] 输出: @@ -127,7 +135,7 @@ async function main() { content: SSA_INTENT_PROMPT, model_config: { model: 'deepseek-v3', temperature: 0.3, maxTokens: 2048 }, status: 'ACTIVE', - changelog: `Phase Q v1.0: 5 组 Few-Shot + Confidence Rubric 客观化`, + changelog: `Phase Q v1.1: 6 组 Few-Shot (增加统计学意义示例) + Confidence Rubric 客观化`, created_by: 'system-seed', } }); diff --git a/backend/src/modules/ssa/services/QueryService.ts b/backend/src/modules/ssa/services/QueryService.ts index 08db6159..09b30064 100644 --- a/backend/src/modules/ssa/services/QueryService.ts +++ b/backend/src/modules/ssa/services/QueryService.ts @@ -411,7 +411,9 @@ export class QueryService { if (query.includes('比较') || query.includes('差异') || query.includes('不同') || query.includes('有没有效')) { goal = 'comparison'; - } else if (query.includes('相关') || query.includes('关系') || query.includes('关联')) { + } else if (query.includes('相关') || query.includes('关系') || query.includes('关联') + || query.includes('统计学意义') || query.includes('显著') || query.includes('检验') + || query.includes('p值') || query.includes('有无差别')) { goal = 'correlation'; } else if (query.includes('影响') || query.includes('因素') || query.includes('预测') || query.includes('回归')) { goal = 'regression'; diff --git a/backend/src/modules/ssa/services/WorkflowExecutorService.ts b/backend/src/modules/ssa/services/WorkflowExecutorService.ts index 7daaa137..967e5e2f 100644 --- a/backend/src/modules/ssa/services/WorkflowExecutorService.ts +++ b/backend/src/modules/ssa/services/WorkflowExecutorService.ts @@ -395,6 +395,11 @@ export class WorkflowExecutorService extends EventEmitter { }); // 调用 R 服务 + logger.info('[SSA:Executor] Calling R service', { + step: step.stepOrder, + toolCode: step.toolCode, + inputParams: step.inputParams, + }); const response = await this.rClient.post(`/api/v1/skills/${step.toolCode}`, { data_source: dataSource, params: step.inputParams, @@ -410,6 +415,16 @@ export class WorkflowExecutorService extends EventEmitter { if (response.data.status === 'error' || response.data.status === 'blocked') { const rMsg = response.data.message || '执行失败'; const classified = classifyRError(rMsg); + + logger.warn('[SSA:Executor] R tool returned error', { + step: step.stepOrder, + toolCode: step.toolCode, + rMessage: rMsg, + rErrorCode: response.data.error_code, + rUserHint: response.data.user_hint, + classifiedCode: classified.code, + }); + return { stepOrder: step.stepOrder, toolCode: step.toolCode, diff --git a/backend/src/modules/ssa/types/query.types.ts b/backend/src/modules/ssa/types/query.types.ts index 0d97372f..022d4960 100644 --- a/backend/src/modules/ssa/types/query.types.ts +++ b/backend/src/modules/ssa/types/query.types.ts @@ -74,13 +74,47 @@ export interface PrunedProfile { // 2. LLM 原始输出的 Zod Schema(静态版本) // ──────────────────────────────────────────── +const VALID_VAR_TYPES = ['continuous', 'binary', 'categorical', 'ordinal', 'datetime'] as const; + +const VAR_TYPE_ALIAS: Record = { + numeric: 'continuous', + integer: 'continuous', + int: 'continuous', + float: 'continuous', + double: 'continuous', + number: 'continuous', + real: 'continuous', + factor: 'categorical', + string: 'categorical', + text: 'categorical', + character: 'categorical', + char: 'categorical', + nominal: 'categorical', + boolean: 'binary', + bool: 'binary', + logical: 'binary', + dichotomous: 'binary', + date: 'datetime', + time: 'datetime', + timestamp: 'datetime', +}; + +function normalizeVarType(val: unknown): VariableType { + if (typeof val !== 'string') return 'continuous'; + const lower = val.toLowerCase().trim(); + if ((VALID_VAR_TYPES as readonly string[]).includes(lower)) return lower as VariableType; + return VAR_TYPE_ALIAS[lower] ?? 'continuous'; +} + +const varTypeSchema = z.preprocess(normalizeVarType, z.enum(VALID_VAR_TYPES)); + /** LLM 直接输出的 JSON 结构(Zod 校验用) */ export const LLMIntentOutputSchema = z.object({ goal: z.enum(['comparison', 'correlation', 'regression', 'descriptive', 'cohort_study']), outcome_var: z.string().nullable().default(null), - outcome_type: z.enum(['continuous', 'binary', 'categorical', 'ordinal', 'datetime']).nullable().default(null), + outcome_type: varTypeSchema.nullable().default(null), predictor_vars: z.array(z.string()).default([]), - predictor_types: z.array(z.enum(['continuous', 'binary', 'categorical', 'ordinal', 'datetime'])).default([]), + predictor_types: z.array(varTypeSchema).default([]), grouping_var: z.string().nullable().default(null), design: z.enum(['independent', 'paired', 'longitudinal', 'cross_sectional']).default('independent'), confidence: z.number().min(0).max(1).default(0.5), diff --git a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md index 45e29b89..edacaf56 100644 --- a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md @@ -1,19 +1,17 @@ # SSA智能统计分析模块 - 当前状态与开发指南 -> **文档版本:** v2.0 +> **文档版本:** v2.1 > **创建日期:** 2026-02-18 > **最后更新:** 2026-02-21 > **维护者:** 开发团队 -> **当前状态:** 🎉 **QPER 智能化主线闭环完成!Q→P→E→R 端到端 40/40 通过** +> **当前状态:** 🎉 **QPER 主线闭环 + 集成测试通过 + 统一状态管理重构完成** > **文档目的:** 快速了解SSA模块状态,为新AI助手提供上下文 > -> **🎉 重大里程碑(2026-02-21):** -> - ✅ **QPER 四层架构主线闭环** — Phase E+ / Q / P / R 全部完成,93.5h 计划工时 -> - ✅ **端到端测试 40/40 通过** — 两条完整链路(差异比较 + 相关分析)全部跑通 -> - ✅ **LLM 智能意图理解** — 自然语言→四维信息提取,Confidence=0.95 -> - ✅ **配置化决策表驱动** — JSON 驱动方法选择,热更新 API,方法学团队可配置 -> - ✅ **LLM 论文级结论生成** — 6 要素结论 + 槽位注入反幻觉 + Zod 强校验 + 敏感性冲突准则 -> - ✅ **四层降级体系** — 每层 LLM 失败时自动 fallback,系统不中断 +> **最新进展(2026-02-21 晚):** +> - ✅ **前后端集成测试** — 7 个 Bug 全部修复(R 引擎防御、意图识别、前端状态) +> - ✅ **统一状态管理重构** — 消除 isWorkflowMode 双轨逻辑,AnalysisRecord 成为唯一数据源 +> - ✅ **多任务切换** — 点击不同卡片正确显示各自的分析计划和结果 +> - ✅ **R 代码完整性** — 多步骤分析的所有步骤代码均可下载/复制 --- @@ -26,9 +24,10 @@ | **模块名称** | SSA - 智能统计分析 (Smart Statistical Analysis) | | **模块定位** | AI驱动的"白盒"统计分析系统 | | **架构模式** | **QPER — Query → Planner → Execute → Reflection** | +| **前端状态模型** | **Unified Record Architecture — 一次分析 = 一个 Record = N 个 Steps** | | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **目标用户** | 临床研究人员、生物统计师 | -| **开发状态** | 🎉 **QPER 主线闭环完成,Phase Deploy 待启动** | +| **开发状态** | 🎉 **QPER 主线闭环 + 集成测试通过,Phase Deploy 待启动** | ### 核心目标 @@ -79,6 +78,38 @@ --- +## 🎨 前端架构:统一状态管理 + +> **2026-02-21 重构完成** — 消除 isWorkflowMode 双轨逻辑 + +### 数据模型 + +```typescript +AnalysisRecord { + id: string; // = workflowId or generated + query: string; // 用户原始问题 + createdAt: string; + status: 'planning' | 'executing' | 'completed' | 'error'; + plan: WorkflowPlan | null; // 统一用 WorkflowPlan(单步也是 1 步的 Plan) + steps: WorkflowStepResult[]; // 统一用步骤数组 + progress: number; // 0-100 + conclusionReport: ConclusionReport | null; +} +``` + +### Store 结构 + +- `analysisHistory: AnalysisRecord[]` — 所有分析记录 +- `currentRecordId: string | null` — 当前激活的记录 +- 派生:`currentRecord = analysisHistory.find(r => r.id === currentRecordId)` +- 操作:`addRecord(query, plan)` / `updateRecord(id, patch)` / `selectRecord(id)` + +### 已删除的全局字段 + +`currentPlan`、`executionResult`、`traceSteps`、`workflowPlan`、`workflowSteps`、`workflowProgress`、`conclusionReport`、`isWorkflowMode` 及所有对应 setter。 + +--- + ## 📋 开发进度 | Phase | 任务 | 工时 | 状态 | 完成日期 | @@ -91,21 +122,23 @@ | **Phase Q** | **LLM 意图理解** | **33h** | ✅ **已完成** | 2026-02-21 | | **Phase P** | **决策表 + 流程模板** | **23h** | ✅ **已完成** | 2026-02-21 | | **Phase R** | **LLM 论文级结论** | **22h** | ✅ **已完成** | 2026-02-21 | +| **集成测试** | **Bug 修复 + 统一状态管理重构** | **~4h** | ✅ **已完成** | 2026-02-21 | | Phase Deploy | 工具补齐 + 部署上线 | 37h | 📋 待开始 | - | | Phase Q+ | 人机协同增强 | 20h | 📋 待开始 | - | +| **QPER 透明化** | **Pipeline 可观测性增强** | TBD | 📋 待开始 | - | ### 已完成核心功能 | 组件 | 完成项 | 状态 | |------|--------|------| -| **R 服务** | 7 个 R 工具(T 检验、描述统计、卡方、Logistic、相关分析等)+ Block-based 输出 | ✅ | -| **Q 层** | QueryService + LLM Intent + Zod 动态防幻觉 + 追问卡片 + DataProfile 增强 | ✅ | +| **R 服务** | 7 个 R 工具 + Block-based 输出 + 防御性编程(NA 安全) | ✅ | +| **Q 层** | QueryService + LLM Intent + Zod 防幻觉 + 追问卡片 + 统计学意义关键词增强 | ✅ | | **P 层** | ConfigLoader + DecisionTable + FlowTemplate + PlannedTrace + 热更新 API | ✅ | -| **E 层** | WorkflowExecutor + RClient + SSE 实时进度 + 错误分类映射 | ✅ | +| **E 层** | WorkflowExecutor + RClient + SSE 实时进度 + 错误分类映射 + 参数日志 | ✅ | | **R 层** | ReflectionService + 槽位注入 + Zod 校验 + 敏感性冲突准则 + 结论缓存 + Word 增强 | ✅ | -| **前端** | V11 UI + DynamicReport + ClarificationCard + ConclusionReport(渐入动画)+ Word/R 代码导出 | ✅ | +| **前端** | 统一 Record 架构 + 多任务切换 + 已完成标记 + DynamicReport + Word/R 导出 | ✅ | | **Python** | DataProfileService(is_id_like 标记)+ CSV 解析 | ✅ | -| **测试** | QPER 端到端测试 40/40 通过 | ✅ | +| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 | ✅ | --- @@ -137,11 +170,25 @@ backend/src/modules/ssa/ │ └── config.routes.ts # 热更新 API └── ... -backend/scripts/ -├── seed-ssa-intent-prompt.ts # Q 层 Prompt 种子 -├── seed-ssa-reflection-prompt.ts # R 层 Prompt 种子 -├── test-ssa-qper-e2e.ts # QPER 端到端测试 -└── ... +frontend-v2/src/modules/ssa/ +├── stores/ +│ └── ssaStore.ts # Zustand — Unified Record Architecture +├── hooks/ +│ ├── useWorkflow.ts # 工作流 Hook(addRecord/updateRecord) +│ └── useAnalysis.ts # 上传/Legacy 兼容 +├── components/ +│ ├── SSAChatPane.tsx # 对话区(卡片 → selectRecord) +│ ├── SSAWorkspacePane.tsx # 工作区(基于 currentRecord 渲染) +│ ├── SSACodeModal.tsx # R 代码模态框(从 record.steps 聚合) +│ ├── WorkflowTimeline.tsx # 执行计划时间线 +│ └── DynamicReport.tsx # Block-based 结果渲染 +└── types/ + └── index.ts # 前端类型定义 + +r-statistics-service/ +├── plumber.R # API 入口(含参数日志) +└── tools/ + └── descriptive.R # 描述性统计(NA 安全防御) ``` --- @@ -190,6 +237,7 @@ npx tsx scripts/seed-ssa-reflection-prompt.ts |------|------| | **QPER 开发计划(主线)** | `04-开发计划/10-QPER架构开发计划-智能化主线.md` | | **QPER 开发总结** | `06-开发记录/SSA-QPER架构开发总结-2026-02-21.md` | +| **集成测试 Bug 修复** | `06-开发记录/2026-02-21-集成测试Bug修复与统一状态管理重构.md` | | **智能化愿景设计** | `00-系统设计/SSA-Pro 理想状态与智能化愿景设计.md` | | **PRD** | `00-系统设计/PRD SSA-Pro 严谨型智能统计分析模块.md` | | **架构设计 V4** | `00-系统设计/SSA-Pro 严谨型智能统计分析架构设计方案V4.md` | @@ -198,13 +246,25 @@ npx tsx scripts/seed-ssa-reflection-prompt.ts ## 🎯 下一步 -1. **Phase Deploy(37h)** — 补齐 ANOVA / Fisher / Wilcoxon / 线性回归 + 复合工具 ST_BASELINE_TABLE + 部署上线 -2. **Phase Q+(20h)** — 变量数据字典(AI 先猜用户微调)+ 变量选择确认面板(AI 推荐医生确认) -3. **前端集成测试** — 用户手动测试 QPER 全链路的真实交互体验 +### 近期(优先级高) + +1. **QPER 透明化(Pipeline 可观测性)** + - Q 层:展示 LLM 解析结果(goal、变量、置信度)和降级原因 + - P 层:展示决策表匹配过程和流程模板填充参数 + - E 层:实时展示步骤输入参数 + R 返回摘要;开发模式显示 R 原始错误 + - R 层:展示槽位注入内容和 Zod 校验状态 + - 开发者面板:持久化 trace_log + LLM prompt/response 可查看 + +2. **Phase Deploy(37h)** — 补齐 ANOVA / Fisher / Wilcoxon / 线性回归 + 复合工具 ST_BASELINE_TABLE + 部署上线 + +### 中期 + +3. **Phase Q+(20h)** — 变量数据字典(AI 先猜用户微调)+ 变量选择确认面板(AI 推荐医生确认) +4. **前端 UI 细节打磨** — 执行计划格式美化、错误状态视觉增强 --- -**文档版本:** v2.0 +**文档版本:** v2.1 **最后更新:** 2026-02-21 -**当前状态:** 🎉 QPER 主线闭环完成,端到端 40/40 通过 -**下一步:** Phase Deploy 工具补齐 + 部署上线 +**当前状态:** 🎉 QPER 主线闭环 + 集成测试通过 + 统一状态管理重构完成 +**下一步:** QPER 透明化 → Phase Deploy 工具补齐 + 部署上线 diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-21-集成测试Bug修复与统一状态管理重构.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-21-集成测试Bug修复与统一状态管理重构.md new file mode 100644 index 00000000..bf3ff531 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-21-集成测试Bug修复与统一状态管理重构.md @@ -0,0 +1,153 @@ +# SSA 前后端集成测试 Bug 修复与统一状态管理重构 + +> **日期:** 2026-02-21 +> **阶段:** QPER 闭环后首次集成测试 + 架构重构 +> **核心成果:** 修复 7 个集成 Bug + 完成统一状态管理重构(消除双轨逻辑) + +--- + +## 一、Bug 修复清单 + +### Bug 1: 前端错误消息乱码(`分分析行执行执失行败失…undefined重`) + +| 项目 | 内容 | +|------|------| +| **现象** | 执行失败时,对话区错误消息显示为乱码字符 | +| **根因** | 错误消息被 TypeWriter 组件逐字渲染导致字符交错;errText 提取有竞态导致 undefined | +| **修复** | `useWorkflow.ts` — 确保 errText 始终为 String(),错误消息添加 `artifactType: 'execution'` 跳过 TypeWriter | +| **文件** | `frontend-v2/src/modules/ssa/hooks/useWorkflow.ts` | + +### Bug 2: R 引擎 `missing value where TRUE/FALSE needed` 错误 + +| 项目 | 内容 | +|------|------| +| **现象** | 描述性统计执行时 R 崩溃,返回 500 错误 | +| **根因** | `descriptive.R` 中多处 if 条件(group_var 判断、var_types 比较、分组子集)接收到 NA 而非 TRUE/FALSE | +| **修复** | 全面防御性编程:`isTRUE()`、`is.na()` 检查、robust group_var 归一化、sapply 类型推断 tryCatch、可复现代码生成 tryCatch | +| **文件** | `r-statistics-service/tools/descriptive.R` | + +### Bug 3: 分析意图识别错误("统计学意义" → 描述性统计) + +| 项目 | 内容 | +|------|------| +| **现象** | 用户问"Yqol和bmi是否有统计学意义",系统使用描述性统计而非 T 检验/相关分析 | +| **根因** | Q 层 LLM Prompt 和 Regex fallback 缺少"统计学意义""显著""检验""p值"等关键词到 correlation goal 的映射 | +| **修复** | `QueryService.ts` fallbackToRegex 增加统计学意义关键词;`seed-ssa-intent-prompt.ts` 新增 Few-Shot 示例 | +| **文件** | `backend/src/modules/ssa/services/QueryService.ts`, `backend/scripts/seed-ssa-intent-prompt.ts` | + +### Bug 4: 步骤 2(Logistic 回归)结果不显示 + +| 项目 | 内容 | +|------|------| +| **现象** | R 返回 status="warning"(ggplot2 废弃警告),前端仅过滤 status="success",导致步骤 2 结果丢失 | +| **根因** | 前端 SSAWorkspacePane、SSACodeModal 只识别 `success`,不识别 `warning` | +| **修复** | 引入 `stepHasResult` 辅助函数:`(s.status === 'success' \|\| s.status === 'warning') && s.result`,替换所有 6 处过滤 | +| **文件** | `frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx`, `SSACodeModal.tsx` | + +### Bug 5: R 代码下载不完整(仅步骤 1) + +| 项目 | 内容 | +|------|------| +| **现象** | 多步骤分析完成后,下载的 R 代码只包含步骤 1(描述统计),缺少步骤 2(回归) | +| **根因** | 与 Bug 4 同源 — SSACodeModal 过滤条件只匹配 `success` | +| **修复** | 随 Bug 4 一并修复 | + +### Bug 6: 多任务状态混淆(点击旧卡片显示新任务结果) + +| 项目 | 内容 | +|------|------| +| **现象** | 完成两次分析后,点击第 1 次的卡片显示的是第 2 次的结果 | +| **根因** | workflowPlan/workflowSteps/conclusionReport 等是全局单例,切换记录时未正确同步 | +| **修复** | 通过统一状态管理重构彻底解决(见下文第二节) | + +### Bug 7: 后端/R 引擎调试日志不足 + +| 项目 | 内容 | +|------|------| +| **现象** | R 执行失败时无法定位具体哪些变量传入导致错误 | +| **修复** | `WorkflowExecutorService.ts` 增加 R 调用前参数日志;`plumber.R` 增加参数接收日志和错误日志 | +| **文件** | `backend/src/modules/ssa/services/WorkflowExecutorService.ts`, `r-statistics-service/plumber.R` | + +--- + +## 二、统一状态管理重构 + +### 问题描述 + +前端存在"双轨逻辑": +- **Legacy 路径**:`currentPlan` / `executionResult` / `traceSteps`(全局单例) +- **Workflow 路径**:`workflowPlan` / `workflowSteps` / `workflowProgress` / `conclusionReport`(也是全局单例) +- `isWorkflowMode` 分支在多个组件中分叉 + +多任务场景下,全局单例被最新任务覆盖,导致切换卡片时显示错误数据。 + +### 重构方案 + +**核心思想:一次分析 = 一个 Record = N 个 Steps** + +``` +AnalysisRecord { + id, query, createdAt, + status: 'planning' | 'executing' | 'completed' | 'error', + plan: WorkflowPlan | null, + steps: WorkflowStepResult[], + progress: number, + conclusionReport: ConclusionReport | null, +} +``` + +### 改动文件 + +| 文件 | 改动要点 | +|------|---------| +| `ssaStore.ts` | 删除 12 个全局单例字段及 setter;新增 `addRecord`/`updateRecord`/`selectRecord`;`selectRecord` 只设 currentRecordId,不复制数据 | +| `useWorkflow.ts` | SSE 事件处理只调用 `updateRecord(rid, patch)`;删除对 `setWorkflowSteps` 等全局 setter 的调用 | +| `useAnalysis.ts` | `generatePlan` 改为调用 `addRecord` 创建 1-step Record | +| `SSAChatPane.tsx` | SAP/Result 卡片统一行为:`selectRecord(recordId)` + 打开工作区;已完成记录显示绿色"已完成"徽章 | +| `SSAWorkspacePane.tsx` | 所有渲染基于 `currentRecord`;删除 `isWorkflowMode` 分支;phase 直接使用 `record.status` | +| `SSACodeModal.tsx` | 从 `currentRecord.steps` 聚合代码;删除 Legacy fallback | +| `ssa-workspace.css` | 新增 `.sap-card-badge` 样式 | + +### 额外修复 + +- **executeWorkflow 跨 hook 实例问题**:`SSAChatPane` 和 `SSAWorkspacePane` 各自有独立的 `useWorkflow()` 实例,`generateWorkflowPlan` 在 ChatPane 实例中设置了 `currentRecordIdRef`,但 `executeWorkflow` 在 WorkspacePane 实例中执行时 ref 为 null。修复:fallback 到 store 的 `currentRecordId`。 + +--- + +## 三、未完成工作与后续计划 + +### QPER 透明化与可观测性 + +目前 QPER 四层的执行细节对用户不够透明,出错时用户难以了解具体原因,开发调试也依赖日志查看: + +1. **Q 层透明化** + - 展示 LLM 意图解析的 ParsedQuery 结果(goal、变量、设计类型、置信度) + - 低置信度时展示降级原因("LLM 超时,使用关键词匹配") + +2. **P 层透明化** + - 展示决策表匹配过程(匹配了哪条规则、得分) + - 展示流程模板填充参数 + +3. **E 层透明化** + - 实时展示每个步骤的输入参数和 R 返回结果摘要 + - 错误时展示 R 原始错误信息(开发模式) + +4. **R 层透明化** + - 展示 LLM 结论生成的槽位注入内容 + - Zod 校验失败时展示具体字段错误 + +5. **开发调试增强** + - 持久化 trace_log 到数据库 + - 前端"开发者面板"查看完整 QPER pipeline 日志 + - LLM 调用的 prompt/response 可查看 + +### 其他待完成项 + +- Phase Deploy:补齐 ANOVA / Fisher / Wilcoxon / 线性回归等 R 工具 +- Phase Q+:变量数据字典 + 变量选择确认面板 +- 前端 UI 细节打磨:执行计划格式美化、错误状态视觉增强 + +--- + +**测试状态:** 手动集成测试通过(多任务切换、卡片状态、R 代码完整性) +**影响范围:** 纯前端重构 + R 脚本防御性修复 + 后端日志增强,后端 API 不变 diff --git a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx index 30ce15e6..1ea04e13 100644 --- a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx @@ -24,6 +24,7 @@ import { } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useSSAStore } from '../stores/ssaStore'; +import type { AnalysisRecord } from '../stores/ssaStore'; import { useAnalysis } from '../hooks/useAnalysis'; import { useWorkflow } from '../hooks/useWorkflow'; import type { SSAMessage } from '../types'; @@ -40,16 +41,15 @@ export const SSAChatPane: React.FC = () => { mountedFile, setMountedFile, setCurrentSession, - setActivePane, setWorkspaceOpen, - currentPlan, isLoading, isExecuting, error, setError, addToast, addMessage, - selectAnalysisRecord, + selectRecord, + analysisHistory, dataProfile, dataProfileLoading, } = useSSAStore(); @@ -78,10 +78,9 @@ export const SSAChatPane: React.FC = () => { }, []); useEffect(() => { - // 延迟滚动,确保 DOM 更新完成 const timer = setTimeout(scrollToBottom, 100); return () => clearTimeout(timer); - }, [messages, currentPlan, scrollToBottom]); + }, [messages, scrollToBottom]); const handleBack = () => { navigate(-1); @@ -154,18 +153,20 @@ export const SSAChatPane: React.FC = () => { const query = inputValue; setInputValue(''); + // Immediately show user message in chat + addMessage({ + id: crypto.randomUUID(), + role: 'user', + content: query, + createdAt: new Date().toISOString(), + }); + try { if (currentSession?.id) { // Phase Q: 先做意图解析,低置信度时追问 const intentResp = await parseIntent(currentSession.id, query); if (intentResp.needsClarification && intentResp.clarificationCards?.length > 0) { - addMessage({ - id: crypto.randomUUID(), - role: 'user', - content: query, - createdAt: new Date().toISOString(), - }); addMessage({ id: crypto.randomUUID(), role: 'assistant', @@ -180,7 +181,7 @@ export const SSAChatPane: React.FC = () => { return; } - // 置信度足够 → 直接生成工作流计划 + // 置信度足够 → 直接生成工作流计划(不再重复添加用户消息) await generateWorkflowPlan(currentSession.id, query); } else { await generatePlan(query); @@ -235,15 +236,13 @@ export const SSAChatPane: React.FC = () => { } }; - // 打开工作区,可选择特定的分析记录 const handleOpenWorkspace = useCallback((recordId?: string) => { if (recordId) { - selectAnalysisRecord(recordId); + selectRecord(recordId); } else { setWorkspaceOpen(true); - setActivePane('sap'); } - }, [selectAnalysisRecord, setWorkspaceOpen, setActivePane]); + }, [selectRecord, setWorkspaceOpen]); const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes}B`; @@ -321,21 +320,31 @@ export const SSAChatPane: React.FC = () => { msg.content )} - {/* SAP 卡片 - 只有消息中明确标记为 sap 类型时才显示 */} - {msg.artifactType === 'sap' && msg.recordId && ( - - )} + + + ); + })()} ); diff --git a/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx b/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx index 3d28ec67..dd80af55 100644 --- a/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx +++ b/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx @@ -1,85 +1,79 @@ /** - * SSACodeModal - V11 R代码模态框 - * - * 100% 还原 V11 原型图 - * 调用后端 API 获取真实执行代码 + * SSACodeModal - R 代码模态框 (Unified Record Architecture) + * + * 从 currentRecord.steps 聚合所有步骤的可复现代码。 */ import React, { useEffect, useState } from 'react'; import { X, Download, Loader2 } from 'lucide-react'; import { useSSAStore } from '../stores/ssaStore'; -import { useAnalysis } from '../hooks/useAnalysis'; export const SSACodeModal: React.FC = () => { - const { codeModalVisible, setCodeModalVisible, executionResult, addToast, isWorkflowMode, workflowSteps } = useSSAStore(); - const { downloadCode } = useAnalysis(); + const { + codeModalVisible, + setCodeModalVisible, + addToast, + currentRecordId, + analysisHistory, + } = useSSAStore(); + const [code, setCode] = useState(''); const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - if (codeModalVisible) { - loadCode(); - } - }, [codeModalVisible]); + const record = currentRecordId + ? analysisHistory.find((r) => r.id === currentRecordId) ?? null + : null; - const loadCode = async () => { + useEffect(() => { + if (!codeModalVisible) return; setIsLoading(true); try { - if (isWorkflowMode && workflowSteps.length > 0) { - const allCode = workflowSteps - .filter(s => s.status === 'success' && s.result) - .map(s => { + const steps = record?.steps ?? []; + const successSteps = steps.filter( + (s) => (s.status === 'success' || s.status === 'warning') && s.result + ); + if (successSteps.length > 0) { + const allCode = successSteps + .map((s) => { const stepCode = (s.result as any)?.reproducible_code; const header = `# ========================================\n# 步骤 ${s.step_number}: ${s.tool_name}\n# ========================================\n`; - return header + (stepCode || `# 该步骤暂无可用代码`); + return header + (stepCode || '# 该步骤暂无可用代码'); }) .join('\n\n'); - setCode(allCode || '# 暂无可用代码\n# 请先执行分析'); - } else { - const result = await downloadCode(); - const text = await result.blob.text(); - setCode(text); - } - } catch (error) { - if (executionResult?.reproducibleCode) { - setCode(executionResult.reproducibleCode); + setCode(allCode); } else { setCode('# 暂无可用代码\n# 请先执行分析'); } } finally { setIsLoading(false); } - }; + }, [codeModalVisible, record]); if (!codeModalVisible) return null; - const handleClose = () => { - setCodeModalVisible(false); + const handleClose = () => setCodeModalVisible(false); + + const generateFilename = () => { + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + const title = (record?.plan?.title || 'analysis') + .replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_') + .slice(0, 30); + return `SSA_${title}_${ts}.R`; }; - const handleDownload = async () => { + const handleDownload = () => { try { - if (isWorkflowMode && code) { - const blob = new Blob([code], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'workflow_analysis.R'; - a.click(); - URL.revokeObjectURL(url); - addToast('R 脚本已下载', 'success'); - handleClose(); - } else { - const result = await downloadCode(); - const url = URL.createObjectURL(result.blob); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename; - a.click(); - URL.revokeObjectURL(url); - addToast('R 脚本已下载', 'success'); - handleClose(); - } - } catch (error) { + const blob = new Blob([code], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = generateFilename(); + a.click(); + URL.revokeObjectURL(url); + addToast('R 脚本已下载', 'success'); + handleClose(); + } catch { addToast('下载失败', 'error'); } }; @@ -101,7 +95,7 @@ export const SSACodeModal: React.FC = () => { - +
{isLoading ? (
diff --git a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx index 1d720be3..a0e727ba 100644 --- a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx @@ -1,95 +1,78 @@ /** - * SSAWorkspacePane - V11 右侧工作区 - * - * 单页滚动布局:SAP → 执行日志 → 分析结果 - * 支持步骤条导航和渐进式内容展示 + * SSAWorkspacePane - V11 右侧工作区 (Unified Record Architecture) + * + * 所有渲染基于 currentRecord,无 isWorkflowMode 分支。 + * record.status 即 phase: planning → executing → completed / error */ -import React, { useState, useEffect, useRef } from 'react'; -import { +import React, { useEffect, useRef } from 'react'; +import { X, - Play, - FileDown, + Play, + FileDown, Code, CheckCircle, XCircle, Loader2, - Shield, - Star, Lightbulb, CornerDownRight, - AlertTriangle, RefreshCw, FileQuestion, - BarChart3, ImageOff, StopCircle, } from 'lucide-react'; import { useSSAStore } from '../stores/ssaStore'; -import { useAnalysis } from '../hooks/useAnalysis'; +import type { AnalysisRecord } from '../stores/ssaStore'; import { useWorkflow } from '../hooks/useWorkflow'; -import type { TraceStep, ReportBlock } from '../types'; +import type { TraceStep, ReportBlock, WorkflowStepResult } from '../types'; import { WorkflowTimeline } from './WorkflowTimeline'; import { DynamicReport } from './DynamicReport'; import { exportBlocksToWord } from '../utils/exportBlocksToWord'; -type ExecutionPhase = 'planning' | 'executing' | 'completed' | 'error'; +const stepHasResult = (s: WorkflowStepResult) => + (s.status === 'success' || s.status === 'warning') && s.result; export const SSAWorkspacePane: React.FC = () => { - const { - workspaceOpen, - setWorkspaceOpen, - currentPlan, - traceSteps, - executionResult: analysisResult, + const { + workspaceOpen, + setWorkspaceOpen, setCodeModalVisible, addToast, currentRecordId, currentSession, - // Phase 2A: 多步骤工作流状态 - isWorkflowMode, - workflowPlan, - workflowSteps, - workflowProgress, - conclusionReport, + analysisHistory, + updateRecord, } = useSSAStore(); - const { executeAnalysis, exportReport, isExecuting } = useAnalysis(); const { executeWorkflow, cancelWorkflow, isExecuting: isWorkflowExecuting } = useWorkflow(); - const [elapsedTime, setElapsedTime] = useState(0); - const [executionError, setExecutionError] = useState(null); - const [phase, setPhase] = useState('planning'); - + const executionRef = useRef(null); const resultRef = useRef(null); const containerRef = useRef(null); - const hasWorkflowResults = isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result); + // ---- Derive everything from the current record ---- + const record: AnalysisRecord | null = + currentRecordId + ? analysisHistory.find((r) => r.id === currentRecordId) ?? null + : null; - // 当切换记录或执行结果变化时,同步 phase 状态 + const plan = record?.plan ?? null; + const steps = record?.steps ?? []; + const progress = record?.progress ?? 0; + const conclusion = record?.conclusionReport ?? null; + const phase = record?.status ?? 'planning'; + const hasResults = steps.some(stepHasResult); + + // Scroll to results when switching to a completed record useEffect(() => { - if (isExecuting || isWorkflowExecuting) return; - - if (analysisResult || hasWorkflowResults) { - setPhase('completed'); - } else { - setPhase('planning'); + if (!currentRecordId || isWorkflowExecuting) return; + if (phase === 'completed' && hasResults) { + setTimeout(() => { + resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 350); } - setExecutionError(null); - }, [currentRecordId, analysisResult, isExecuting, isWorkflowExecuting, hasWorkflowResults]); + }, [currentRecordId]); - useEffect(() => { - let timer: NodeJS.Timeout; - if (isExecuting) { - timer = setInterval(() => { - setElapsedTime((t) => t + 1); - }, 1000); - } else { - setElapsedTime(0); - } - return () => clearInterval(timer); - }, [isExecuting]); - - // 根据执行状态自动滚动到对应区块 + // Auto-scroll during phase transitions useEffect(() => { if (phase === 'executing' && executionRef.current) { executionRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); @@ -100,59 +83,40 @@ export const SSAWorkspacePane: React.FC = () => { } }, [phase]); - const handleClose = () => { - setWorkspaceOpen(false); - }; + const handleClose = () => setWorkspaceOpen(false); const handleRun = async () => { - setPhase('executing'); - setExecutionError(null); - + if (!plan || !currentSession || !record) return; try { - // Phase 2A: 多步骤工作流模式 - if (isWorkflowMode && workflowPlan && currentSession) { - await executeWorkflow(currentSession.id, workflowPlan.workflow_id); - setPhase('completed'); - } else if (currentPlan) { - // 单步骤模式(兼容原有逻辑) - await executeAnalysis(); - setPhase('completed'); - } + await executeWorkflow(currentSession.id, plan.workflow_id); } catch (err: any) { - const errorMsg = err?.message || '执行失败,请重试'; - setExecutionError(errorMsg); - setPhase('error'); - addToast(errorMsg, 'error'); + addToast(err?.message || '执行失败,请重试', 'error'); } }; const handleCancel = () => { cancelWorkflow(); - setPhase('planning'); + if (record) updateRecord(record.id, { status: 'planning' }); addToast('分析已取消', 'info'); }; const handleRetry = () => { - setExecutionError(null); - setPhase('planning'); + if (record) updateRecord(record.id, { status: 'planning', steps: [], progress: 0 }); }; const handleExportReport = async () => { try { - // 优先使用 block-based 导出 - const allBlocks = workflowSteps - .filter(s => s.status === 'success') - .flatMap(s => { + const allBlocks = steps + .filter(stepHasResult) + .flatMap((s) => { const r = s.result as any; - return s.reportBlocks ?? r?.report_blocks ?? []; - }) as ReportBlock[]; + return (s.reportBlocks ?? r?.report_blocks ?? []) as ReportBlock[]; + }); if (allBlocks.length > 0) { await exportBlocksToWord(allBlocks, { - title: workflowPlan?.title || currentSession?.title || '统计分析报告', + title: plan?.title || currentSession?.title || '统计分析报告', }); - } else { - await exportReport(); } addToast('报告导出成功', 'success'); } catch (err: any) { @@ -160,9 +124,7 @@ export const SSAWorkspacePane: React.FC = () => { } }; - const handleExportCode = () => { - setCodeModalVisible(true); - }; + const handleExportCode = () => setCodeModalVisible(true); const scrollToSection = (section: 'sap' | 'execution' | 'result') => { const refs: Record> = { @@ -178,12 +140,11 @@ export const SSAWorkspacePane: React.FC = () => { return (
- {/* 顶部工具栏 */} + {/* Header + step bar */}
- {/* 步骤条 */}
- - -
+
- {/* 导出报告按钮 - 有结果时显示 */} - {(analysisResult || hasWorkflowResults) && ( - + {hasResults && ( + <> + + + )} - {/* 查看代码按钮 - 有结果时显示 */} - {(analysisResult || hasWorkflowResults) && ( - - )} - {/* 关闭按钮 */} -
- {/* 工作区画布 - 单页滚动 */} + {/* Canvas — single scroll */}
- - {/* 空状态 - 同时检查旧版 currentPlan 和新版 workflowPlan */} - {!currentPlan && !workflowPlan && ( + {/* Empty state */} + {!plan && (

暂无分析计划

- 请在左侧对话区上传数据文件或描述研究目标,
+ 请在左侧对话区上传数据文件或描述研究目标, +
AI 将自动为您生成统计分析计划。

)} - {/* ========== 区块 1: SAP 分析计划 ========== */} - {(currentPlan || workflowPlan) && ( + {/* ===== Block 1: SAP ===== */} + {plan && (
分析计划
- - {/* Phase 2A: 多步骤工作流时间线 */} - {isWorkflowMode && workflowPlan ? ( -
- s.status === 'running')?.step_number} - isExecuting={isWorkflowExecuting} - /> - - {/* 执行按钮 */} -
- {(isExecuting || isWorkflowExecuting) ? ( - - ) : phase !== 'planning' && conclusionReport ? ( - - ) : ( - - )} -
-
- ) : currentPlan && ( -
-

- 研究课题:{currentPlan.title || currentPlan.description?.split(',')[0] || '统计分析'} -

-
- {/* 推荐统计方法 */} -
-

1. 推荐统计方法

-
-
- - 首选:{currentPlan.recommendedMethod || currentPlan.toolName || '独立样本 T 检验 (Independent T-Test)'} -
-
-
-
自变量 (X)
- {currentPlan.parameters?.groupVar || currentPlan.parameters?.group_var || '-'} - (分类) -
-
-
因变量 (Y)
- {currentPlan.parameters?.valueVar || currentPlan.parameters?.value_var || '-'} - (数值) -
-
-
-
+
+ s.status === 'running')?.step_number} + isExecuting={isWorkflowExecuting} + /> - {/* 统计护栏 */} -
-

2. 统计护栏与执行策略

-
    - {(currentPlan.guardrails || []).length > 0 ? ( - currentPlan.guardrails.map((guardrail, idx) => ( -
  • - -
    - {guardrail.checkName} - - {guardrail.actionType === 'Switch' - ? `若检验未通过,将自动切换为 ${guardrail.actionTarget || '备选方法'}` - : guardrail.actionType === 'Warn' - ? '若检验未通过,将显示警告信息' - : '若检验未通过,将阻止执行'} - -
    -
  • - )) - ) : ( -
  • - -
    - 正态性假设检验 (Shapiro-Wilk) - - 系统将在核心计算前执行检查。若 P < 0.05,将触发降级策略。 - -
    -
  • - )} -
-
-
- - {/* 执行按钮 */}
- + {isWorkflowExecuting ? ( + + ) : phase === 'completed' ? ( + + ) : phase === 'error' ? ( + + ) : ( + + )}
-
- )} +
)} - {/* ========== 区块 2: 执行日志 ========== */} - {(phase === 'executing' || phase === 'completed' || phase === 'error' || traceSteps.length > 0 || workflowSteps.length > 0) && ( + {/* ===== Block 2: Execution log ===== */} + {steps.length > 0 && (
执行日志
- - {/* Phase 2A: 多步骤工作流执行进度 - 复用 MVP 风格 */} - {isWorkflowMode && workflowSteps.length > 0 ? ( -
- {(isWorkflowExecuting || phase === 'executing') && ( -
- -

正在执行多步骤分析...

- {Math.round(workflowProgress)}% -
- )} - - {phase === 'completed' && ( -
- - 全部步骤执行完成 - - 共 {workflowSteps.length} 个步骤 - -
- )} - - {/* 复用 MVP 的 terminal-box 风格 */} -
-
-
- {workflowSteps.flatMap((step) => { + +
+ {phase === 'executing' && ( +
+ +

正在执行多步骤分析...

+ {Math.round(progress)}% +
+ )} + + {phase === 'completed' && ( +
+ + 全部步骤执行完成 + 共 {steps.length} 个步骤 +
+ )} + + {phase === 'error' && ( +
+ + 执行出错 +
+ )} + +
+
+
+ {steps + .flatMap((step) => { const logs: Array<{ name: string; status: string; message?: string }> = []; + const errorText = + typeof step.error === 'object' + ? (step.error as any)?.userHint || (step.error as any)?.message || JSON.stringify(step.error) + : step.error; logs.push({ name: `[步骤 ${step.step_number}] ${step.tool_name}`, - status: step.status === 'success' ? 'success' : step.status === 'running' ? 'running' : step.status === 'failed' ? 'error' : 'pending', - message: step.status === 'running' ? '执行中...' : step.status === 'success' ? '完成' : step.status === 'failed' ? step.error : '' + status: + step.status === 'success' || step.status === 'warning' + ? 'success' + : step.status === 'running' + ? 'running' + : step.status === 'failed' + ? 'error' + : 'pending', + message: + step.status === 'running' + ? '执行中...' + : step.status === 'success' + ? '完成' + : step.status === 'warning' + ? '完成(有警告)' + : step.status === 'failed' + ? errorText || '执行失败' + : '', }); - (step.logs || []).forEach(log => { + (step.logs || []).forEach((log) => { logs.push({ name: ` → ${log}`, status: 'info' }); }); return logs; - }).map((log, idx) => ( + }) + .map((log, idx) => ( ))} -
- ) : ( - <> - {/* 单步骤执行中状态 */} - {phase === 'executing' && !executionError && ( -
-
- -

正在调用云端 R 引擎...

- {elapsedTime}s -
-
-
-
- {traceSteps.length === 0 ? ( -
- - 等待 R 引擎响应... -
- ) : ( - traceSteps.map((step, idx) => ( - - )) - )} -
-
-
- )} - - {/* 执行完成后的日志(折叠显示) */} - {phase === 'completed' && traceSteps.length > 0 && ( -
-
- - 执行完成 - 耗时 {analysisResult?.executionMs || 0}ms -
-
-
- {traceSteps.map((step, idx) => ( - - ))} -
-
-
- )} - - )} - - {/* 执行错误 */} - {phase === 'error' && executionError && ( -
-
- -
-

执行失败

-

{executionError}

- -
- )} +
)} - {/* ========== 区块 3: 分析结果 ========== */} - {(analysisResult || (isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result))) && ( + {/* ===== Block 3: Results ===== */} + {hasResults && (
分析结果
- - {/* Phase 2A: 多步骤工作流结果 - 复用 MVP 风格 */} - {isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result) ? ( -
- {/* AI 解读 - 使用结论报告的摘要 */} - {conclusionReport && ( -
- -
-

AI 统计解读

-

{conclusionReport.executive_summary || '多步骤分析已完成,请查看下方各步骤结果。'}

-
+ +
+ {conclusion && ( +
+ +
+

AI 统计解读

+

{conclusion.executive_summary || '多步骤分析已完成,请查看下方各步骤结果。'}

- )} +
+ )} - {/* 各步骤结果汇总 — 优先 Block-based,fallback 旧 MVP 风格 */} - {workflowSteps.filter(s => s.status === 'success' && s.result).map((step, stepIdx) => { - const r = step.result as any; - const blocks: ReportBlock[] | undefined = - step.reportBlocks ?? r?.report_blocks; - const hasBlocks = blocks && blocks.length > 0; + {steps.filter(stepHasResult).map((step, stepIdx) => { + const r = step.result as any; + const blocks: ReportBlock[] | undefined = step.reportBlocks ?? r?.report_blocks; + const hasBlocks = blocks && blocks.length > 0; - return ( + return (

步骤 {step.step_number}. {step.tool_name} - {step.duration_ms && 耗时 {step.duration_ms}ms} + {step.duration_ms && ( + 耗时 {step.duration_ms}ms + )}

- - {/* Block-based 渲染(优先) */} {hasBlocks ? ( ) : ( )}
- ); - })} + ); + })} - {/* 综合执行时间 */} -
- 总执行耗时: {workflowSteps.reduce((sum, s) => sum + (s.duration_ms || 0), 0)}ms -
-
- ) : analysisResult && ( -
- {/* AI 解读 */} -
- -
-

AI 统计解读

-

- {analysisResult.interpretation || generateDefaultInterpretation(analysisResult)} -

+
+ 总执行耗时: {steps.reduce((sum, s) => sum + (s.duration_ms || 0), 0)}ms
- - {/* 护栏检查结果 */} - {analysisResult.guardrailResults?.length > 0 && ( -
- {analysisResult.guardrailResults.map((gr, idx) => ( -
- {gr.passed ? ( - - ) : gr.actionType === 'Switch' ? ( - - ) : ( - - )} - {gr.message} - {gr.actionTaken && gr.switchTarget && ( - - → {gr.switchTarget} - - )} -
- ))} -
- )} - - {/* 动态表格 */} - {analysisResult.result_table ? ( -
-

Table 1. 统计分析结果

-
- - - - {analysisResult.result_table.headers.map((h, i) => ( - - ))} - - - - {analysisResult.result_table.rows.map((row, i) => ( - - {row.map((cell, j) => ( - - ))} - - ))} - -
{h}
- {formatCell(cell)} -
-
-
- ) : ( -
-

统计量汇总

-
-
-
统计方法
-
{analysisResult.results?.method || '-'}
-
-
-
统计量
-
{analysisResult.results?.statistic?.toFixed(4) || '-'}
-
-
-
P 值
-
- {(analysisResult.results as any)?.p_value_fmt || formatPValue(analysisResult.results?.pValue ?? (analysisResult.results as any)?.p_value)} -
-
- {analysisResult.results?.effectSize && ( -
-
效应量
-
{analysisResult.results.effectSize.toFixed(3)}
-
- )} -
-
- )} - - {/* 动态图表 */} - {analysisResult.plots?.length > 0 ? ( -
-

Figure 1. 分布可视化

- -
- ) : ( -
-

Figure 1. 分布可视化

-
-
- - 暂无图表数据 -
-
-
- )} - - {/* 执行时间 */} -
- 执行耗时: {analysisResult.executionMs}ms -
-
- )}
)} -
@@ -701,6 +386,8 @@ export const SSAWorkspacePane: React.FC = () => { ); }; +// ==================== Helper Components ==================== + interface PlotData { type: string; title: string; @@ -713,9 +400,7 @@ const ChartImage: React.FC<{ plot: PlotData }> = ({ plot }) => { const imageSrc = React.useMemo(() => { if (!plot.imageBase64) return ''; - if (plot.imageBase64.startsWith('data:')) { - return plot.imageBase64; - } + if (plot.imageBase64.startsWith('data:')) return plot.imageBase64; return `data:image/png;base64,${plot.imageBase64}`; }, [plot.imageBase64]); @@ -735,7 +420,7 @@ const ChartImage: React.FC<{ plot: PlotData }> = ({ plot }) => {
)} - {plot.title} = ({ plot }) => { ); }; -const generateDefaultInterpretation = (result: any): string => { - const pValue = result.results?.pValue; - const method = result.results?.method || '统计检验'; - - if (pValue === undefined) { - return '分析已完成,请查看详细结果。'; - } - - const significant = pValue < 0.05; - const pText = pValue < 0.001 ? '< 0.001' : pValue.toFixed(4); - - if (significant) { - return `${method}结果显示,组间差异具有统计学意义 (P = ${pText})。建议结合临床意义进行解读。`; - } - return `${method}结果显示,未发现具有统计学意义的差异 (P = ${pText})。`; -}; - const formatPValue = (p: number | undefined): string => { if (p === undefined) return '-'; if (p < 0.001) return '< 0.001 ***'; @@ -813,7 +481,7 @@ const TraceLogItem: React.FC<{ step: TraceStep; index: number }> = ({ step, inde }; return ( -
@@ -824,13 +492,8 @@ const TraceLogItem: React.FC<{ step: TraceStep; index: number }> = ({ step, inde ); }; -/** - * 描述性统计专用结果展示组件 - * R 服务返回: { summary: { n_total, n_variables, n_numeric, n_categorical }, variables: { varName: { ...stats } } } - * 数值型变量: { variable, type, n, mean, sd, median, q1, q3, min, max, formatted, missing } - * 分类型变量: { variable, type, n, missing, levels: [{ level, n, pct, formatted }] } - * 分组模式: { variable, type, by_group: { groupName: { ...stats } } } - */ +// ==================== Descriptive Result View ==================== + const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { if (!result) return null; @@ -851,9 +514,10 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { return 'unknown'; }; - const varEntries = variables && typeof variables === 'object' && !Array.isArray(variables) - ? Object.entries(variables) - : []; + const varEntries = + variables && typeof variables === 'object' && !Array.isArray(variables) + ? Object.entries(variables) + : []; const numericVars = varEntries.filter(([, v]) => classifyVar(v) === 'numeric'); const catVars = varEntries.filter(([, v]) => classifyVar(v) === 'categorical'); @@ -862,7 +526,11 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { if (vs.mean !== undefined) return vs; if (vs.by_group) { const groups = Object.entries(vs.by_group); - return { variable: vs.variable, n: groups.reduce((s, [, g]: [string, any]) => s + (g.n || 0), 0), by_group: vs.by_group }; + return { + variable: vs.variable, + n: groups.reduce((s, [, g]: [string, any]) => s + (g.n || 0), 0), + by_group: vs.by_group, + }; } return vs; }; @@ -897,15 +565,8 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { - - - - - - - - - + + @@ -953,11 +614,7 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => {
变量N均值 ± 标准差中位数Q1Q3最小值最大值缺失变量N均值 ± 标准差中位数Q1Q3最小值最大值缺失
- - - - - + @@ -967,7 +624,12 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { return Object.entries(vs.by_group).flatMap(([gName, gStats]: [string, any]) => { const levels = gStats.levels || []; if (levels.length === 0) { - return []; + return [ + + + + , + ]; } return levels.map((lv: any, i: number) => ( @@ -982,7 +644,12 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { } const levels = vs.levels || []; if (levels.length === 0) { - return []; + return [ + + + + , + ]; } return levels.map((lv: any, i: number) => ( @@ -1009,10 +676,8 @@ const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { ); }; -/** - * LegacyStepResultView — 旧版(非 Block-based)步骤结果渲染 - * 当 R 工具未返回 report_blocks 时作为 fallback 使用 - */ +// ==================== Legacy Step Result Fallback ==================== + const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step, stepIdx }) => { const r = step.result as any; if (!r) return null; @@ -1041,9 +706,7 @@ const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step, {pVal !== undefined && (
P 值
-
- {pFmt} -
+
{pFmt}
)} {r?.effect_size !== undefined && ( @@ -1051,7 +714,9 @@ const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step,
效应量
{typeof r.effect_size === 'object' - ? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ') + ? Object.entries(r.effect_size) + .map(([k, v]) => `${k}=${Number(v).toFixed(3)}`) + .join(', ') : Number(r.effect_size).toFixed(3)}
@@ -1081,8 +746,7 @@ const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step, {r.group_stats.map((g: any, i: number) => ( - - + @@ -1121,12 +785,18 @@ const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step,
变量N类别频数 (%)缺失变量N类别频数 (%)缺失
{vs.variable || varName} [{gName}]{gStats.n ?? '-'}--{gStats.missing ?? 0}
{vs.variable || varName} [{gName}]{gStats.n ?? '-'}--{gStats.missing ?? 0}
{vs.variable || varName}{vs.n ?? '-'}--{vs.missing ?? 0}
{vs.variable || varName}{vs.n ?? '-'}--{vs.missing ?? 0}
{g.group}{g.n}{g.group}{g.n} {g.mean !== undefined ? Number(g.mean).toFixed(4) : '-'} {g.sd !== undefined ? Number(g.sd).toFixed(4) : '-'}
- {r.result_table.headers.map((h: string, i: number) => )} - {r.result_table.rows.map((row: any[], i: number) => ( - {row.map((cell, j) => ( - - ))} - ))} + + {r.result_table.headers.map((h: string, i: number) => )} + + + {r.result_table.rows.map((row: any[], i: number) => ( + + {row.map((cell, j) => ( + + ))} + + ))} +
{h}
{formatCell(cell)}
{h}
{formatCell(cell)}
@@ -1138,11 +808,12 @@ const LegacyStepResultView: React.FC<{ step: any; stepIdx: number }> = ({ step,

Figure {stepIdx + 1}. 可视化

{r.plots.map((plot: any, plotIdx: number) => ( - + ))}
)} diff --git a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx index bf58d608..ea9d9fb1 100644 --- a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx +++ b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx @@ -1,9 +1,20 @@ /** - * 多步骤工作流时间线组件 - * - * Phase 2A: 在工作区显示多步骤分析计划的垂直时间线 + * WorkflowTimeline - 多步骤分析计划时间线 + * + * 精美卡片式布局:标题区 → 护栏横幅 → 步骤卡片列表 → 底部提示 */ import React from 'react'; +import { + FlaskConical, + BarChart3, + Shield, + AlertTriangle, + CheckCircle, + XCircle, + Loader2, + Clock, + ListChecks, +} from 'lucide-react'; import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types'; interface WorkflowTimelineProps { @@ -13,109 +24,151 @@ interface WorkflowTimelineProps { isExecuting?: boolean; } -const statusConfig: Record = { - pending: { icon: '○', color: '#94a3b8', bg: '#f1f5f9' }, - running: { icon: '◎', color: '#2563eb', bg: '#eff6ff', animation: 'pulse' }, - success: { icon: '✓', color: '#059669', bg: '#ecfdf5' }, - failed: { icon: '✕', color: '#dc2626', bg: '#fef2f2' }, - skipped: { icon: '⊘', color: '#94a3b8', bg: '#f8fafc' }, - warning: { icon: '!', color: '#d97706', bg: '#fffbeb' }, + pending: { borderColor: '#e2e8f0', dotBg: '#f1f5f9', dotColor: '#94a3b8' }, + running: { borderColor: '#3b82f6', dotBg: '#dbeafe', dotColor: '#2563eb' }, + success: { borderColor: '#10b981', dotBg: '#d1fae5', dotColor: '#059669' }, + failed: { borderColor: '#ef4444', dotBg: '#fee2e2', dotColor: '#dc2626' }, + skipped: { borderColor: '#e2e8f0', dotBg: '#f8fafc', dotColor: '#94a3b8' }, + warning: { borderColor: '#f59e0b', dotBg: '#fef3c7', dotColor: '#d97706' }, }; -const toolIcons: Record = { - 't_test': '📊', - 'welch_t_test': '📊', - 'paired_t_test': '🔗', - 'mann_whitney_u': '📈', - 'wilcoxon_signed_rank': '📈', - 'chi_square_test': '📋', - 'fisher_exact_test': '📋', - 'one_way_anova': '📉', - 'kruskal_wallis': '📉', - 'pearson_correlation': '🔄', - 'spearman_correlation': '🔄', - 'logistic_regression': '📐', - 'default': '🔬', +const StatusDot: React.FC<{ status: WorkflowStepStatus | 'pending'; num: number }> = ({ status, num }) => { + const s = statusStyle[status]; + if (status === 'running') { + return ( + + + + ); + } + if (status === 'success') { + return ( + + + + ); + } + if (status === 'failed') { + return ( + + + + ); + } + return ( + + {num} + + ); }; -const getToolIcon = (toolCode: string): string => { - return toolIcons[toolCode] || toolIcons.default; +const HIDDEN_PARAMS = new Set(['session_id', 'sessionId', 'data_path', 'dataPath', 'original_filename']); + +const formatValue = (value: unknown): string => { + if (value === null || value === undefined) return '—'; + if (Array.isArray(value)) { + if (value.length === 0) return '—'; + if (value.length <= 4) return value.join(', '); + return `${value.slice(0, 3).join(', ')} … 等${value.length}项`; + } + const s = String(value); + return s.length > 50 ? s.slice(0, 47) + '…' : s; }; -interface StepItemProps { +const PARAM_LABELS: Record = { + variables: '分析变量', + outcome_var: '结局变量 (Y)', + predictors: '自变量 (X)', + group_var: '分组变量', + value_var: '因变量', + method: '统计方法', + alternative: '假设方向', + conf_level: '置信水平', + var_x: '变量 X', + var_y: '变量 Y', +}; + +interface StepCardProps { step: WorkflowStepDef; result?: WorkflowStepResult; isLast: boolean; isCurrent: boolean; } -const StepItem: React.FC = ({ step, result, isLast, isCurrent }) => { - const status = result?.status || 'pending'; - const config = statusConfig[status]; - +const StepCard: React.FC = ({ step, result, isLast, isCurrent }) => { + const status: WorkflowStepStatus | 'pending' = result?.status || 'pending'; + const s = statusStyle[status]; + + const visibleParams = step.params + ? Object.entries(step.params).filter(([k]) => !HIDDEN_PARAMS.has(k)) + : []; + return ( -
-
-
- {config.icon} -
- {!isLast &&
} +
+ {/* Left rail */} +
+ + {!isLast && ( +
+ )}
- -
-
- 步骤 {step.step_number} - {getToolIcon(step.tool_code)} - {step.tool_name} - {step.is_sensitivity && ( - 敏感性分析 - )} - {result?.duration_ms && ( - {result.duration_ms}ms + + {/* Card */} +
+
+
+ 步骤 {step.step_number} + {step.tool_name} + {step.is_sensitivity && 敏感性} +
+ {result?.duration_ms != null && ( + {result.duration_ms}ms )}
- -
{step.description}
+ + {step.description && ( +

{step.description}

+ )} {step.switch_condition && ( -
- 🛡️ 护栏:{step.switch_condition} +
+ + {step.switch_condition}
)} - - {step.params && Object.keys(step.params).length > 0 && ( -
- {Object.entries(step.params).slice(0, 3).map(([key, value]) => ( - - {key}: {String(value)} - + + {visibleParams.length > 0 && ( +
+ {visibleParams.slice(0, 5).map(([key, value]) => ( +
+ {PARAM_LABELS[key] || key} + {formatValue(value)} +
))}
)} {result?.status === 'success' && result.result?.p_value !== undefined && ( -
- +
+ p = {result.result.p_value < 0.001 ? '< 0.001' : result.result.p_value.toFixed(4)} - {result.result.p_value < 0.05 && ( - 显著 * - )} + {result.result.p_value < 0.05 && 显著 *}
)} {result?.status === 'failed' && result.error && ( -
- ⚠️ - {result.error} +
+ + {typeof result.error === 'object' ? (result.error as any)?.userHint || JSON.stringify(result.error) : result.error}
)}
@@ -129,78 +182,82 @@ export const WorkflowTimeline: React.FC = ({ currentStep, isExecuting = false, }) => { - const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => { - return stepResults.find(r => r.step_number === stepNumber); - }; - - const completedSteps = stepResults.filter(r => r.status === 'success').length; - const progress = plan.total_steps > 0 ? (completedSteps / plan.total_steps) * 100 : 0; + const getResult = (n: number) => stepResults.find(r => r.step_number === n); + const done = stepResults.filter(r => r.status === 'success').length; + const pct = plan.total_steps > 0 ? (done / plan.total_steps) * 100 : 0; return ( -
-
-
-

{plan.title}

-

{plan.description}

+
+ {/* Header */} +
+
+
-
- - 共 {plan.total_steps} 个分析步骤 - - {plan.estimated_time_seconds && ( - - 预计 {Math.ceil(plan.estimated_time_seconds / 60)} 分钟 +
+

{plan.title}

+

{plan.description}

+
+ + + {plan.total_steps} 个分析步骤 - )} + {plan.estimated_time_seconds != null && ( + + + 预计 {plan.estimated_time_seconds < 60 ? `${plan.estimated_time_seconds}秒` : `${Math.ceil(plan.estimated_time_seconds / 60)}分钟`} + + )} +
+ {/* EPV Warning */} {plan.epv_warning && ( -
- ⚠️ +
+ {plan.epv_warning}
)} + {/* Guardrail Banner */} {plan.planned_trace?.fallbackTool && ( -
- 🛡️ +
+ - 主方法:{plan.planned_trace.primaryTool} -  → 若{plan.planned_trace.switchCondition}则自动降级为 {plan.planned_trace.fallbackTool} + 主方法 {plan.planned_trace.primaryTool} + {' → '}若 {plan.planned_trace.switchCondition} 则自动降级为 {plan.planned_trace.fallbackTool}
)} + {/* Progress */} {isExecuting && ( -
-
-
+
+
+
- - {completedSteps}/{plan.total_steps} 完成 - + {done}/{plan.total_steps}
)} -
- {plan.steps.map((step, index) => ( - + {plan.steps.map((step, i) => ( + ))}
+ {/* Footer */} {!isExecuting && stepResults.length === 0 && ( -
- ✨ 分析计划已就绪,点击「开始分析」执行 +
+ + 分析计划已就绪,点击「开始分析」执行
)}
diff --git a/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts b/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts index 94fa6c2a..e9f2d923 100644 --- a/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts +++ b/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts @@ -1,15 +1,14 @@ /** - * SSA 分析相关的自定义 Hook - * - * 遵循规范: - * - 使用 apiClient(带认证的 axios 实例) - * - 使用 getAccessToken 处理文件上传 + * SSA 分析相关的自定义 Hook (Updated for Unified Record Architecture) + * + * uploadData / generatePlan 仍由 SSAChatPane 使用。 + * executeAnalysis / exportReport 保留但已切换到 record-based 存储。 */ import { useCallback, useState } from 'react'; import apiClient from '@/common/api/axios'; import { getAccessToken } from '@/framework/auth/api'; import { useSSAStore } from '../stores/ssaStore'; -import type { AnalysisPlan, ExecutionResult, SSAMessage, TraceStep } from '../types'; +import type { AnalysisPlan, ExecutionResult, SSAMessage } from '../types'; import { Document, Packer, @@ -60,18 +59,13 @@ interface UseAnalysisReturn { export function useAnalysis(): UseAnalysisReturn { const { currentSession, - currentPlan, - setCurrentPlan, - setExecutionResult, - setTraceSteps, - updateTraceStep, addMessage, setLoading, setExecuting, isExecuting, setError, - addAnalysisRecord, - updateAnalysisRecord, + addRecord, + updateRecord, } = useSSAStore(); const [isUploading, setIsUploading] = useState(false); @@ -85,7 +79,6 @@ export function useAnalysis(): UseAnalysisReturn { formData.append('file', file); try { - // 文件上传使用 fetch + 手动添加认证头(不设置 Content-Type) const token = getAccessToken(); const headers: HeadersInit = {}; if (token) { @@ -98,10 +91,7 @@ export function useAnalysis(): UseAnalysisReturn { body: formData, }); - if (!response.ok) { - throw new Error('上传失败'); - } - + if (!response.ok) throw new Error('上传失败'); const result = await response.json(); setUploadProgress(100); return result; @@ -115,10 +105,7 @@ export function useAnalysis(): UseAnalysisReturn { const generatePlan = useCallback( async (query: string): Promise => { - if (!currentSession) { - throw new Error('请先上传数据'); - } - + if (!currentSession) throw new Error('请先上传数据'); setLoading(true); try { @@ -134,31 +121,38 @@ export function useAnalysis(): UseAnalysisReturn { `${API_BASE}/sessions/${currentSession.id}/plan`, { query } ); - const plan: AnalysisPlan = response.data; - - // 创建分析记录(支持多任务) - const recordId = addAnalysisRecord(query, plan); - - // 消息中携带 recordId,便于点击时定位 + + const recordId = addRecord(query, { + workflow_id: plan.id || `legacy_${Date.now()}`, + title: plan.title || plan.toolName || '统计分析', + total_steps: 1, + steps: [{ + step_number: 1, + tool_code: plan.toolCode || 'unknown', + tool_name: plan.toolName || '统计分析', + description: plan.description || '', + params: plan.parameters || {}, + }], + } as any); + const planMessage: SSAMessage = { id: crypto.randomUUID(), role: 'assistant', content: `已生成分析方案:${plan.toolName}\n\n${plan.description}`, artifactType: 'sap', - recordId, // 关联到分析记录 + recordId, createdAt: new Date().toISOString(), }; addMessage(planMessage); - const confirmMessage: SSAMessage = { + addMessage({ id: crypto.randomUUID(), role: 'assistant', content: '请确认数据映射并执行分析。', artifactType: 'confirm', createdAt: new Date().toISOString(), - }; - addMessage(confirmMessage); + }); return plan; } catch (error) { @@ -168,411 +162,132 @@ export function useAnalysis(): UseAnalysisReturn { setLoading(false); } }, - [currentSession, addMessage, setCurrentPlan, setLoading, setError, addAnalysisRecord] + [currentSession, addMessage, setLoading, setError, addRecord] ); const executePlan = useCallback( async (_planId: string): Promise => { - if (!currentSession) { - throw new Error('请先上传数据'); - } + if (!currentSession) throw new Error('请先上传数据'); - // 获取当前 plan(从 store) - const plan = useSSAStore.getState().currentPlan; - if (!plan) { - throw new Error('请先生成分析计划'); - } + const record = (() => { + const s = useSSAStore.getState(); + return s.currentRecordId + ? s.analysisHistory.find((r) => r.id === s.currentRecordId) ?? null + : null; + })(); + const planStep = record?.plan?.steps?.[0]; + if (!planStep) throw new Error('请先生成分析计划'); setExecuting(true); - setExecutionResult(null); - - const initialSteps: TraceStep[] = [ - { index: 0, name: '参数验证', status: 'pending', message: '等待执行' }, - { index: 1, name: '护栏检查', status: 'pending', message: '等待执行' }, - { index: 2, name: '统计计算', status: 'pending', message: '等待执行' }, - { index: 3, name: '可视化生成', status: 'pending', message: '等待执行' }, - { index: 4, name: '结果格式化', status: 'pending', message: '等待执行' }, - ]; - setTraceSteps(initialSteps); + const rid = record!.id; + updateRecord(rid, { status: 'executing', steps: [], progress: 0 }); try { - updateTraceStep(0, { status: 'running' }); - - // 发送完整的 plan 对象(转换为后端格式) const response = await apiClient.post( `${API_BASE}/sessions/${currentSession.id}/execute`, - { + { plan: { - tool_code: plan.toolCode, - tool_name: plan.toolName, - params: plan.parameters, - guardrails: plan.guardrails - } + tool_code: planStep.tool_code, + tool_name: planStep.tool_name, + params: planStep.params, + }, } ); - const result: ExecutionResult = response.data; - initialSteps.forEach((_, i) => { - updateTraceStep(i, { status: 'success' }); + updateRecord(rid, { + status: 'completed', + progress: 100, + steps: [{ + step_number: 1, + tool_code: planStep.tool_code, + tool_name: planStep.tool_name, + status: 'success', + result: result as any, + started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), + duration_ms: result.executionMs, + logs: [], + }], }); - result.guardrailResults?.forEach((gr) => { - if (gr.actionType === 'Switch' && gr.actionTaken) { - updateTraceStep(1, { - status: 'switched', - actionType: 'Switch', - switchTarget: gr.switchTarget, - message: gr.message, - }); - } else if (gr.actionType === 'Warn') { - updateTraceStep(1, { - actionType: 'Warn', - message: gr.message, - }); - } - }); - - setExecutionResult(result); - - // 更新分析记录 - const recordId = useSSAStore.getState().currentRecordId; - if (recordId) { - updateAnalysisRecord(recordId, { - executionResult: result, - traceSteps: useSSAStore.getState().traceSteps, - }); - } - - const resultMessage: SSAMessage = { + addMessage({ id: crypto.randomUUID(), role: 'assistant', content: result.interpretation || '分析完成,请查看右侧结果面板。', artifactType: 'result', - recordId: recordId || undefined, // 关联到分析记录 + recordId: rid, createdAt: new Date().toISOString(), - }; - addMessage(resultMessage); + }); return result; } catch (error: any) { - initialSteps.forEach((step, i) => { - if (step.status === 'running' || step.status === 'pending') { - updateTraceStep(i, { status: 'failed' }); - } - }); - - // 提取 R 服务返回的具体错误信息 + updateRecord(rid, { status: 'error' }); const errorData = error.response?.data; - const errorMessage = errorData?.user_hint || errorData?.error || + const errorMessage = errorData?.user_hint || errorData?.error || (error instanceof Error ? error.message : '执行出错'); - setError(errorMessage); throw new Error(errorMessage); } finally { setExecuting(false); } }, - [ - currentSession, - addMessage, - setExecutionResult, - setTraceSteps, - updateTraceStep, - setExecuting, - setError, - ] + [currentSession, addMessage, setExecuting, setError, updateRecord] ); const executeAnalysis = useCallback(async (): Promise => { - if (!currentPlan) { - throw new Error('请先生成分析计划'); - } - return executePlan(currentPlan.id); - }, [currentPlan, executePlan]); + return executePlan('current'); + }, [executePlan]); const downloadCode = useCallback(async (): Promise => { - if (!currentSession) { - throw new Error('请先上传数据'); - } - + if (!currentSession) throw new Error('请先上传数据'); const response = await apiClient.get( `${API_BASE}/sessions/${currentSession.id}/download-code`, { responseType: 'blob' } ); - const contentDisposition = response.headers['content-disposition']; let filename = `analysis_${currentSession.id}.R`; - if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (filenameMatch) { let extractedName = filenameMatch[1].replace(/['"]/g, ''); - try { - extractedName = decodeURIComponent(extractedName); - } catch { - // 解码失败,使用原始值 - } - if (extractedName) { - filename = extractedName; - } + try { extractedName = decodeURIComponent(extractedName); } catch { /* ok */ } + if (extractedName) filename = extractedName; } } - return { blob: response.data, filename }; }, [currentSession]); const exportReport = useCallback(async () => { - const result = useSSAStore.getState().executionResult; - const plan = useSSAStore.getState().currentPlan; - const session = useSSAStore.getState().currentSession; - const mountedFile = useSSAStore.getState().mountedFile; - const { isWorkflowMode, workflowSteps, workflowPlan, conclusionReport } = useSSAStore.getState(); - - if (isWorkflowMode && workflowSteps.some(s => s.status === 'success')) { - return exportWorkflowReport(workflowSteps, workflowPlan, conclusionReport, session, mountedFile); - } - - if (!result) { + const state = useSSAStore.getState(); + const record = state.currentRecordId + ? state.analysisHistory.find((r) => r.id === state.currentRecordId) ?? null + : null; + if (!record) { setError('暂无分析结果可导出'); return; } - const now = new Date(); - const dateStr = now.toLocaleString('zh-CN'); - const pValue = result.results?.pValue ?? (result.results as any)?.p_value; - const pValueStr = pValue !== undefined - ? (pValue < 0.001 ? '< 0.001' : pValue.toFixed(4)) - : '-'; - - const groupVar = String(plan?.parameters?.groupVar || plan?.parameters?.group_var || '-'); - const valueVar = String(plan?.parameters?.valueVar || plan?.parameters?.value_var || '-'); - const dataFileName = mountedFile?.name || session?.title || '数据文件'; - const rowCount = mountedFile?.rowCount || session?.dataSchema?.rowCount || 0; - - const createTableRow = (cells: string[], isHeader = false) => { - return new TableRow({ - children: cells.map(text => new TableCell({ - children: [new Paragraph({ - children: [new TextRun({ text, bold: isHeader })], - })], - width: { size: 100 / cells.length, type: WidthType.PERCENTAGE }, - })), - }); - }; - - const tableBorders = { - top: { style: BorderStyle.SINGLE, size: 1 }, - bottom: { style: BorderStyle.SINGLE, size: 1 }, - left: { style: BorderStyle.SINGLE, size: 1 }, - right: { style: BorderStyle.SINGLE, size: 1 }, - insideHorizontal: { style: BorderStyle.SINGLE, size: 1 }, - insideVertical: { style: BorderStyle.SINGLE, size: 1 }, - }; - - const sections: (Paragraph | Table)[] = []; - let sectionNum = 1; - - sections.push( - new Paragraph({ - text: '统计分析报告', - heading: HeadingLevel.TITLE, - alignment: AlignmentType.CENTER, - }), - new Paragraph({ text: '' }), - new Paragraph({ children: [ - new TextRun({ text: '研究课题:', bold: true }), - new TextRun(session?.title || plan?.title || '未命名分析'), - ]}), - new Paragraph({ children: [ - new TextRun({ text: '生成时间:', bold: true }), - new TextRun(dateStr), - ]}), - new Paragraph({ text: '' }), + const steps = record.steps.filter( + (s) => (s.status === 'success' || s.status === 'warning') && s.result ); - - sections.push( - new Paragraph({ - text: `${sectionNum++}. 数据描述`, - heading: HeadingLevel.HEADING_1, - }), - new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - borders: tableBorders, - rows: [ - createTableRow(['项目', '内容'], true), - createTableRow(['数据文件', dataFileName]), - createTableRow(['样本量', `${rowCount} 行`]), - createTableRow(['分组变量 (X)', groupVar]), - createTableRow(['分析变量 (Y)', valueVar]), - ], - }), - new Paragraph({ text: '' }), - ); - - sections.push( - new Paragraph({ - text: `${sectionNum++}. 分析方法`, - heading: HeadingLevel.HEADING_1, - }), - new Paragraph({ - text: `本研究采用 ${result.results?.method || plan?.toolName || '统计检验'} 方法,` + - `比较 ${groupVar} 分组下 ${valueVar} 的差异。`, - }), - new Paragraph({ text: '' }), - ); - - if (result.guardrailResults?.length) { - sections.push( - new Paragraph({ - text: `${sectionNum++}. 前提条件检验`, - heading: HeadingLevel.HEADING_1, - }), - new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - borders: tableBorders, - rows: [ - createTableRow(['检查项', '结果', '说明'], true), - ...result.guardrailResults.map((gr: { checkName: string; passed: boolean; actionType: string; message: string }) => createTableRow([ - gr.checkName, - gr.passed ? '通过' : gr.actionType === 'Switch' ? '降级' : '未通过', - gr.message, - ])), - ], - }), - new Paragraph({ text: '' }), - ); + if (steps.length === 0) { + setError('暂无分析结果可导出'); + return; } - const resultAny = result.results as any; - const groupStats = resultAny?.groupStats || resultAny?.group_stats; - if (groupStats?.length) { - sections.push( - new Paragraph({ - text: `${sectionNum++}. 描述性统计`, - heading: HeadingLevel.HEADING_1, - }), - new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - borders: tableBorders, - rows: [ - createTableRow(['分组', '样本量 (n)', '均值 (Mean)', '标准差 (SD)'], true), - ...groupStats.map((gs: any) => createTableRow([ - gs.group, - String(gs.n), - gs.mean?.toFixed(4) || '-', - gs.sd?.toFixed(4) || '-', - ])), - ], - }), - new Paragraph({ text: '' }), - ); - } + const session = state.currentSession; + const mountedFile = state.mountedFile; + const conclusion = record.conclusionReport; - sections.push( - new Paragraph({ - text: `${sectionNum++}. 统计检验结果`, - heading: HeadingLevel.HEADING_1, - }), - new Table({ - width: { size: 100, type: WidthType.PERCENTAGE }, - borders: tableBorders, - rows: [ - createTableRow(['指标', '值'], true), - createTableRow(['统计方法', resultAny?.method || '-']), - createTableRow(['统计量 (t/F/χ²)', resultAny?.statistic?.toFixed(4) || '-']), - createTableRow(['自由度 (df)', resultAny?.df?.toFixed(2) || '-']), - createTableRow(['P 值', pValueStr]), - ...(resultAny?.effectSize ? [createTableRow(['效应量', resultAny.effectSize.toFixed(3)])] : []), - ...(resultAny?.confInt ? [createTableRow(['95% 置信区间', `[${resultAny.confInt[0]?.toFixed(4)}, ${resultAny.confInt[1]?.toFixed(4)}]`])] : []), - ], - }), - new Paragraph({ text: '' }), - ); - - const plotData = result.plots?.[0]; - if (plotData) { - const imageBase64 = typeof plotData === 'string' ? plotData : plotData.imageBase64; - if (imageBase64) { - try { - const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, ''); - const imageBuffer = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); - - sections.push( - new Paragraph({ - text: `${sectionNum++}. 可视化结果`, - heading: HeadingLevel.HEADING_1, - }), - new Paragraph({ - children: [ - new ImageRun({ - data: imageBuffer, - transformation: { width: 450, height: 300 }, - type: 'png', - }), - ], - alignment: AlignmentType.CENTER, - }), - new Paragraph({ - text: `图 1. ${valueVar} 在 ${groupVar} 分组下的分布`, - alignment: AlignmentType.CENTER, - }), - new Paragraph({ text: '' }), - ); - } catch (e) { - console.warn('图片导出失败', e); - } - } - } - - sections.push( - new Paragraph({ - text: `${sectionNum++}. 结论`, - heading: HeadingLevel.HEADING_1, - }), - new Paragraph({ - text: result.interpretation || - (pValue !== undefined && pValue < 0.05 - ? `${groupVar} 分组间的 ${valueVar} 差异具有统计学意义 (P = ${pValueStr})。` - : `${groupVar} 分组间的 ${valueVar} 差异无统计学意义 (P = ${pValueStr})。`), - }), - new Paragraph({ text: '' }), - new Paragraph({ text: '' }), - new Paragraph({ - children: [ - new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }), - ], - }), - new Paragraph({ - children: [ - new TextRun({ text: `执行耗时: ${result.executionMs}ms`, italics: true, color: '666666' }), - ], - }), - ); - - const doc = new Document({ - sections: [{ - children: sections, - }], - }); - - const dateTimeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; - const safeFileName = dataFileName.replace(/\.(csv|xlsx|xls)$/i, '').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_'); - - const blob = await Packer.toBlob(doc); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`; - a.click(); - URL.revokeObjectURL(url); + await exportWorkflowReport(steps, record.plan, conclusion, session, mountedFile); }, [setError]); const exportWorkflowReport = async ( - steps: any[], - wfPlan: any, - conclusion: any, - session: any, + steps: any[], + wfPlan: any, + conclusion: any, + session: any, mountedFile: any ) => { const now = new Date(); @@ -603,7 +318,7 @@ export function useAnalysis(): UseAnalysisReturn { let sectionNum = 1; sections.push( - new Paragraph({ text: '多步骤统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }), + new Paragraph({ text: '统计分析报告', heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }), new Paragraph({ text: '' }), new Paragraph({ children: [ new TextRun({ text: '研究课题:', bold: true }), @@ -632,8 +347,7 @@ export function useAnalysis(): UseAnalysisReturn { ); } - const successSteps = steps.filter(s => s.status === 'success' && s.result); - for (const step of successSteps) { + for (const step of steps) { const r = step.result as any; sections.push( new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }), @@ -654,7 +368,6 @@ export function useAnalysis(): UseAnalysisReturn { new Paragraph({ text: '' }), ); } - if (r?.variables && typeof r.variables === 'object') { const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => { if (!v) return 'unknown'; @@ -669,11 +382,9 @@ export function useAnalysis(): UseAnalysisReturn { } return 'unknown'; }; - const varEntries = Object.entries(r.variables); const numericVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'numeric'); const catVars = varEntries.filter(([, v]) => classifyExportVar(v) === 'categorical'); - if (numericVars.length > 0) { const numRows: TableRow[] = [createTableRow(['变量', 'N', '均值 ± 标准差', '中位数', 'Q1', 'Q3', '最小值', '最大值'], true)]; for (const [varName, rawVs] of numericVars) { @@ -703,7 +414,6 @@ export function useAnalysis(): UseAnalysisReturn { new Paragraph({ text: '' }), ); } - if (catVars.length > 0) { sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 })); for (const [varName, rawVs] of catVars) { @@ -747,13 +457,12 @@ export function useAnalysis(): UseAnalysisReturn { if (r?.statistic !== undefined) statsRows.push(createTableRow(['统计量', Number(r.statistic).toFixed(4)])); if (r?.p_value !== undefined) statsRows.push(createTableRow(['P 值', r.p_value_fmt || (r.p_value < 0.001 ? '< 0.001' : Number(r.p_value).toFixed(4))])); if (r?.effect_size !== undefined) { - const esStr = typeof r.effect_size === 'object' + const esStr = typeof r.effect_size === 'object' ? Object.entries(r.effect_size).map(([k, v]) => `${k}=${Number(v).toFixed(3)}`).join(', ') : Number(r.effect_size).toFixed(3); statsRows.push(createTableRow(['效应量', esStr])); } if (r?.conf_int) statsRows.push(createTableRow(['95% CI', `[${r.conf_int.map((v: number) => v.toFixed(4)).join(', ')}]`])); - sections.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: statsRows })); sections.push(new Paragraph({ text: '' })); } @@ -812,7 +521,7 @@ export function useAnalysis(): UseAnalysisReturn { }), new Paragraph({ text: '' }), ); - } catch (e) { /* skip */ } + } catch { /* skip */ } } } } @@ -825,11 +534,10 @@ export function useAnalysis(): UseAnalysisReturn { new Paragraph({ text: '' }), ); } - if (conclusion?.recommendations?.length > 0) { sections.push( new Paragraph({ text: `${sectionNum++}. 建议`, heading: HeadingLevel.HEADING_1 }), - ...conclusion.recommendations.map((r: string) => new Paragraph({ text: `• ${r}` })), + ...conclusion.recommendations.map((rec: string) => new Paragraph({ text: `• ${rec}` })), new Paragraph({ text: '' }), ); } @@ -840,7 +548,7 @@ export function useAnalysis(): UseAnalysisReturn { new TextRun({ text: '本报告由智能统计分析系统自动生成', italics: true, color: '666666' }), ]}), new Paragraph({ children: [ - new TextRun({ text: `总执行耗时: ${steps.reduce((s, st) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }), + new TextRun({ text: `总执行耗时: ${steps.reduce((s: number, st: any) => s + (st.duration_ms || 0), 0)}ms`, italics: true, color: '666666' }), ]}), ); @@ -852,7 +560,7 @@ export function useAnalysis(): UseAnalysisReturn { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `多步骤分析报告_${safeFileName}_${dateTimeStr}.docx`; + a.download = `统计分析报告_${safeFileName}_${dateTimeStr}.docx`; a.click(); URL.revokeObjectURL(url); }; diff --git a/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts b/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts index 94b001d1..4f77ff25 100644 --- a/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts +++ b/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts @@ -1,18 +1,19 @@ /** - * 多步骤工作流 Hook - * - * Phase 2A: 处理数据画像、工作流规划、SSE 执行等 + * 多步骤工作流 Hook (Unified Record Architecture) + * + * 所有分析状态通过 addRecord / updateRecord 写入 AnalysisRecord, + * 不再操作全局单例字段。 */ import { useCallback, useRef } from 'react'; import apiClient from '@/common/api/axios'; import { getAccessToken } from '@/framework/auth/api'; import { useSSAStore } from '../stores/ssaStore'; -import type { - DataProfile, - WorkflowPlan, +import type { AnalysisRecord } from '../stores/ssaStore'; +import type { + DataProfile, + WorkflowPlan, WorkflowStepResult, SSEMessage, - SSAMessage, IntentResult, ClarificationCardData, } from '../types'; @@ -44,14 +45,8 @@ export function useWorkflow(): UseWorkflowReturn { setDataProfile, setDataProfileLoading, dataProfileLoading, - setWorkflowPlan, setWorkflowPlanLoading, workflowPlanLoading, - setWorkflowSteps, - updateWorkflowStep, - setWorkflowProgress, - setConclusionReport, - setIsWorkflowMode, setActivePane, setWorkspaceOpen, addMessage, @@ -59,30 +54,30 @@ export function useWorkflow(): UseWorkflowReturn { isExecuting, setError, addToast, + addRecord, + updateRecord, } = useSSAStore(); const abortControllerRef = useRef(null); const eventSourceRef = useRef(null); + const currentRecordIdRef = useRef(null); + + // ========== Data Profile ========== const generateDataProfile = useCallback(async (sessionId: string): Promise => { setDataProfileLoading(true); setError(null); - try { const response = await apiClient.post(`${API_BASE}/workflow/profile`, { sessionId }); const profile: DataProfile = response.data.profile; - setDataProfile(profile); - - const profileMessage: SSAMessage = { + addMessage({ id: crypto.randomUUID(), role: 'assistant', content: `数据质量核查完成:${profile.quality_grade}级 (${profile.quality_score}分)`, - artifactType: 'sap', + artifactType: 'profile', createdAt: new Date().toISOString(), - }; - addMessage(profileMessage); - + }); return profile; } catch (error: any) { const errorMsg = error.response?.data?.message || error.message || '数据画像生成失败'; @@ -94,51 +89,43 @@ export function useWorkflow(): UseWorkflowReturn { } }, [setDataProfile, setDataProfileLoading, setError, addMessage, addToast]); + // ========== Workflow Plan ========== + const generateWorkflowPlan = useCallback(async ( - sessionId: string, + sessionId: string, query: string ): Promise => { setWorkflowPlanLoading(true); setError(null); - setIsWorkflowMode(true); try { - const userMessage: SSAMessage = { - id: crypto.randomUUID(), - role: 'user', - content: query, - createdAt: new Date().toISOString(), - }; - addMessage(userMessage); - - const response = await apiClient.post(`${API_BASE}/workflow/plan`, { - sessionId, - userQuery: query + const response = await apiClient.post(`${API_BASE}/workflow/plan`, { + sessionId, + userQuery: query, }); const plan: WorkflowPlan = response.data.plan; - - setWorkflowPlan(plan); + + const recordId = addRecord(query, plan); + currentRecordIdRef.current = recordId; + setActivePane('sap'); setWorkspaceOpen(true); - const planMessage: SSAMessage = { + addMessage({ id: crypto.randomUUID(), role: 'assistant', content: `已生成分析方案:${plan.title}\n共 ${plan.total_steps} 个分析步骤`, artifactType: 'sap', + recordId, createdAt: new Date().toISOString(), - }; - addMessage(planMessage); - - const confirmMessage: SSAMessage = { + }); + addMessage({ id: crypto.randomUUID(), role: 'assistant', content: '请确认分析计划并开始执行。', artifactType: 'confirm', createdAt: new Date().toISOString(), - }; - addMessage(confirmMessage); - + }); return plan; } catch (error: any) { const errorMsg = error.response?.data?.message || error.message || '工作流规划失败'; @@ -148,27 +135,16 @@ export function useWorkflow(): UseWorkflowReturn { } finally { setWorkflowPlanLoading(false); } - }, [ - setWorkflowPlan, - setWorkflowPlanLoading, - setIsWorkflowMode, - setActivePane, - setWorkspaceOpen, - addMessage, - setError, - addToast - ]); + }, [setWorkflowPlanLoading, setActivePane, setWorkspaceOpen, addMessage, setError, addToast, addRecord]); + + // ========== Intent Parsing ========== - /** - * Phase Q: 解析用户意图(不直接生成计划) - */ const parseIntent = useCallback(async ( sessionId: string, query: string ): Promise => { setWorkflowPlanLoading(true); setError(null); - try { const response = await apiClient.post(`${API_BASE}/workflow/intent`, { sessionId, @@ -184,9 +160,8 @@ export function useWorkflow(): UseWorkflowReturn { } }, [setWorkflowPlanLoading, setError]); - /** - * Phase Q: 处理用户追问回答 - */ + // ========== Clarification ========== + const handleClarify = useCallback(async ( sessionId: string, userQuery: string, @@ -194,31 +169,28 @@ export function useWorkflow(): UseWorkflowReturn { ): Promise => { setWorkflowPlanLoading(true); setError(null); - try { const response = await apiClient.post(`${API_BASE}/workflow/clarify`, { sessionId, userQuery, selections, }); - const data = response.data as IntentResponse; if (data.plan) { - setWorkflowPlan(data.plan); + const recordId = addRecord(userQuery, data.plan); + currentRecordIdRef.current = recordId; setActivePane('sap'); setWorkspaceOpen(true); - setIsWorkflowMode(true); - addMessage({ id: crypto.randomUUID(), role: 'assistant', content: `已生成分析方案:${data.plan.title}\n共 ${data.plan.total_steps} 个分析步骤`, artifactType: 'sap', + recordId, createdAt: new Date().toISOString(), }); } - return data; } catch (error: any) { const errorMsg = error.response?.data?.message || error.message || '处理追问失败'; @@ -227,70 +199,64 @@ export function useWorkflow(): UseWorkflowReturn { } finally { setWorkflowPlanLoading(false); } - }, [setWorkflowPlanLoading, setError, setWorkflowPlan, setActivePane, setWorkspaceOpen, setIsWorkflowMode, addMessage]); + }, [setWorkflowPlanLoading, setError, addRecord, setActivePane, setWorkspaceOpen, addMessage]); + + // ========== Workflow Execution (SSE) ========== const executeWorkflow = useCallback(async ( - _sessionId: string, + _sessionId: string, workflowId: string ): Promise => { + const rid = currentRecordIdRef.current || useSSAStore.getState().currentRecordId; + if (!rid) throw new Error('No active record'); + setExecuting(true); setActivePane('execution'); - setWorkflowSteps([]); - setWorkflowProgress(0); - setConclusionReport(null); + updateRecord(rid, { status: 'executing', steps: [], progress: 0, conclusionReport: null }); setError(null); const token = getAccessToken(); - + return new Promise((resolve, reject) => { const streamUrl = `${API_BASE}/workflow/${workflowId}/stream`; - abortControllerRef.current = new AbortController(); - + fetch(streamUrl, { method: 'GET', headers: { - 'Authorization': `Bearer ${token}`, - 'Accept': 'text/event-stream', + Authorization: `Bearer ${token}`, + Accept: 'text/event-stream', }, signal: abortControllerRef.current.signal, - }).then(async (response) => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('No response body'); - } + const decoder = new TextDecoder(); + let buffer = ''; - const decoder = new TextDecoder(); - let buffer = ''; + const getSteps = (): WorkflowStepResult[] => { + const rec = useSSAStore.getState().analysisHistory.find((r) => r.id === rid); + return rec?.steps ?? []; + }; - const processLine = (line: string) => { - if (line.startsWith('data:')) { - const jsonStr = line.slice(5).trim(); - if (jsonStr) { - try { - const message: SSEMessage = JSON.parse(jsonStr); - handleSSEMessage(message); - } catch (e) { - console.warn('Failed to parse SSE message:', jsonStr); - } - } - } - }; + const patchSteps = (steps: WorkflowStepResult[], extra?: Partial) => { + updateRecord(rid, { steps, ...extra }); + }; - const handleSSEMessage = (message: SSEMessage) => { - // 兼容后端的驼峰命名和顶层字段 - const toolCode = message.toolCode || message.data?.tool_code || ''; - const toolName = message.toolName || message.data?.tool_name || ''; - const stepNumber = message.step; - - switch (message.type) { - case 'step_start': - if (stepNumber !== undefined) { - const stepResult: WorkflowStepResult = { + const handleSSEMessage = (message: SSEMessage) => { + const toolCode = message.toolCode || message.data?.tool_code || ''; + const toolName = message.toolName || message.data?.tool_name || ''; + const stepNumber = message.step; + + switch (message.type) { + case 'step_start': { + if (stepNumber === undefined) break; + const cur = getSteps(); + if (cur.some((s) => s.step_number === stepNumber)) break; + const newStep: WorkflowStepResult = { step_number: stepNumber, tool_code: toolCode, tool_name: toolName, @@ -298,143 +264,197 @@ export function useWorkflow(): UseWorkflowReturn { started_at: new Date().toISOString(), logs: message.message ? [message.message] : [], }; - const currentSteps = useSSAStore.getState().workflowSteps; - // 避免重复添加 - if (!currentSteps.some(s => s.step_number === stepNumber)) { - setWorkflowSteps([...currentSteps, stepResult]); - } + patchSteps([...cur, newStep]); + break; } - break; - case 'step_progress': - if (stepNumber !== undefined && message.message) { - updateWorkflowStep(stepNumber, { - logs: (useSSAStore.getState().workflowSteps - .find(s => s.step_number === stepNumber)?.logs || []) - .concat(message.message), - }); + case 'step_progress': { + if (stepNumber === undefined || !message.message) break; + const cur = getSteps(); + patchSteps( + cur.map((s) => + s.step_number === stepNumber + ? { ...s, logs: [...(s.logs || []), message.message!] } + : s + ) + ); + break; } - break; - case 'step_complete': - if (stepNumber !== undefined) { + case 'step_complete': { + if (stepNumber === undefined) break; const result = message.result || message.data?.result; const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms; - const reportBlocks = message.reportBlocks - || (result as any)?.report_blocks - || message.data?.reportBlocks; - - updateWorkflowStep(stepNumber, { - status: message.status || message.data?.status || 'success', - completed_at: new Date().toISOString(), - duration_ms: durationMs, - result: result, - reportBlocks: reportBlocks || undefined, - }); - + const reportBlocks = message.reportBlocks || (result as any)?.report_blocks || message.data?.reportBlocks; const totalSteps = message.total_steps || message.totalSteps || 2; const progress = (stepNumber / totalSteps) * 100; - setWorkflowProgress(progress); - } - break; - case 'step_error': - if (stepNumber !== undefined) { - updateWorkflowStep(stepNumber, { - status: 'failed', - completed_at: new Date().toISOString(), - error: message.error || message.message, + const cur = getSteps(); + patchSteps( + cur.map((s) => + s.step_number === stepNumber + ? { + ...s, + status: message.status || message.data?.status || 'success', + completed_at: new Date().toISOString(), + duration_ms: durationMs, + result, + reportBlocks: reportBlocks || undefined, + } + : s + ), + { progress } + ); + break; + } + + case 'step_error': { + if (stepNumber === undefined) break; + const rawErr = message.error || message.message; + const errStr = + typeof rawErr === 'object' + ? (rawErr as any)?.userHint || (rawErr as any)?.message || JSON.stringify(rawErr) + : String(rawErr || '执行失败'); + const cur = getSteps(); + patchSteps( + cur.map((s) => + s.step_number === stepNumber + ? { ...s, status: 'failed', completed_at: new Date().toISOString(), error: errStr } + : s + ) + ); + break; + } + + case 'workflow_complete': { + setExecuting(false); + const rawStatus = (message as any).status ?? (message as any).data?.status; + const isError = rawStatus === 'error' || rawStatus === 'failed'; + const finalSteps = getSteps(); + const hasAnySuccess = finalSteps.some( + (s) => s.status === 'success' || s.status === 'warning' + ); + + if (isError && !hasAnySuccess) { + updateRecord(rid, { status: 'error', progress: 0 }); + const firstErr = finalSteps.find((s) => s.status === 'failed')?.error; + let errText = '执行过程中发生错误'; + if (firstErr) { + errText = + typeof firstErr === 'object' + ? String((firstErr as any)?.userHint || (firstErr as any)?.message || errText) + : String(firstErr); + } + addMessage({ + id: crypto.randomUUID(), + role: 'assistant', + content: `分析执行失败:${errText}`, + artifactType: 'execution', + recordId: rid, + createdAt: new Date().toISOString(), + }); + addToast('工作流执行失败', 'error'); + resolve(); + break; + } + + const conclusion = message.conclusion || null; + updateRecord(rid, { + status: 'completed', + progress: 100, + conclusionReport: conclusion, }); + + if (conclusion) { + setActivePane('result'); + addMessage({ + id: crypto.randomUUID(), + role: 'assistant', + content: `分析完成!${conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`, + artifactType: 'result', + recordId: rid, + createdAt: new Date().toISOString(), + }); + } else if (hasAnySuccess) { + addMessage({ + id: crypto.randomUUID(), + role: 'assistant', + content: '分析执行完成!', + artifactType: 'result', + recordId: rid, + createdAt: new Date().toISOString(), + }); + } else { + addMessage({ + id: crypto.randomUUID(), + role: 'assistant', + content: '分析执行完成,但未生成结论报告。', + createdAt: new Date().toISOString(), + }); + } + addToast('工作流执行完成', 'success'); + resolve(); + break; } - break; - case 'workflow_complete': - setWorkflowProgress(100); - setExecuting(false); - - if (message.conclusion) { - setConclusionReport(message.conclusion); - setActivePane('result'); - - const completeMessage: SSAMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: `分析完成!${message.conclusion.executive_summary?.slice(0, 100) || '查看右侧结果面板获取详细信息'}`, - artifactType: 'result', - createdAt: new Date().toISOString(), - }; - addMessage(completeMessage); - } else { - // 即使没有 conclusion,也标记为完成 - const completeMessage: SSAMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: '分析执行完成!', - artifactType: 'result', - createdAt: new Date().toISOString(), - }; - addMessage(completeMessage); + case 'workflow_error': { + const rawWfErr = message.error || '工作流执行失败'; + const wfErrStr = + typeof rawWfErr === 'object' + ? (rawWfErr as any)?.userHint || (rawWfErr as any)?.message || JSON.stringify(rawWfErr) + : String(rawWfErr); + updateRecord(rid, { status: 'error' }); + setError(wfErrStr); + addToast(wfErrStr, 'error'); + setExecuting(false); + reject(new Error(wfErrStr)); + break; } - - addToast('工作流执行完成', 'success'); - resolve(); - break; - case 'workflow_error': - const errorMsg = message.error || '工作流执行失败'; - setError(errorMsg); - addToast(errorMsg, 'error'); - setExecuting(false); - reject(new Error(errorMsg)); - break; - - case 'connected': - // 连接确认消息,忽略 - break; + case 'connected': + break; + } + }; + + const processLine = (line: string) => { + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trim(); + if (jsonStr) { + try { + handleSSEMessage(JSON.parse(jsonStr)); + } catch (e) { + console.warn('Failed to parse SSE message:', jsonStr); + } + } + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) processLine(line); } - }; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - processLine(line); + if (buffer) processLine(buffer); + }) + .catch((error) => { + if (error.name === 'AbortError') { + addToast('工作流已取消', 'info'); + resolve(); + } else { + const errorMsg = error.message || '工作流执行失败'; + setError(errorMsg); + addToast(errorMsg, 'error'); + reject(error); } - } - - if (buffer) { - processLine(buffer); - } - - }).catch((error) => { - if (error.name === 'AbortError') { - addToast('工作流已取消', 'info'); - resolve(); - } else { - const errorMsg = error.message || '工作流执行失败'; - setError(errorMsg); - addToast(errorMsg, 'error'); - reject(error); - } - setExecuting(false); - }); + setExecuting(false); + }); }); - }, [ - setExecuting, - setActivePane, - setWorkflowSteps, - setWorkflowProgress, - setConclusionReport, - updateWorkflowStep, - addMessage, - setError, - addToast - ]); + }, [setExecuting, setActivePane, addMessage, setError, addToast, updateRecord]); + + // ========== Cancel ========== const cancelWorkflow = useCallback(() => { if (abortControllerRef.current) { diff --git a/frontend-v2/src/modules/ssa/stores/ssaStore.ts b/frontend-v2/src/modules/ssa/stores/ssaStore.ts index fbd936b5..e6136c5e 100644 --- a/frontend-v2/src/modules/ssa/stores/ssaStore.ts +++ b/frontend-v2/src/modules/ssa/stores/ssaStore.ts @@ -1,17 +1,15 @@ /** - * SSA 状态管理 - Zustand Store - * - * V11 版本 - 完全还原原型图设计 - * 支持多任务模式:同一会话中可进行多次分析 + * SSA 状态管理 - Zustand Store (Unified Record Architecture) + * + * 核心思想: 一次分析 = 一个 Record = N 个 Steps + * 所有分析状态统一存储在 AnalysisRecord 中, + * 通过 currentRecordId 派生当前记录,消除全局单例。 */ import { create } from 'zustand'; import type { SSAMode, SSASession, SSAMessage, - AnalysisPlan, - ExecutionResult, - TraceStep, DataProfile, WorkflowPlan, WorkflowStepResult, @@ -32,23 +30,25 @@ interface Toast { type: 'info' | 'success' | 'error'; } -/** 分析记录 - 支持多任务 */ +export type RecordStatus = 'planning' | 'executing' | 'completed' | 'error'; + +/** 统一分析记录 — 无论单步 / 多步,全部走此模型 */ export interface AnalysisRecord { id: string; query: string; createdAt: string; - plan: AnalysisPlan; - executionResult: ExecutionResult | null; - traceSteps: TraceStep[]; + status: RecordStatus; + + plan: WorkflowPlan | null; + steps: WorkflowStepResult[]; + progress: number; + conclusionReport: ConclusionReport | null; } interface SSAState { mode: SSAMode; currentSession: SSASession | null; messages: SSAMessage[]; - currentPlan: AnalysisPlan | null; - executionResult: ExecutionResult | null; - traceSteps: TraceStep[]; isLoading: boolean; isExecuting: boolean; error: string | null; @@ -56,34 +56,24 @@ interface SSAState { activePane: ArtifactPane; mountedFile: MountedFile | null; codeModalVisible: boolean; - + sidebarExpanded: boolean; workspaceOpen: boolean; toasts: Toast[]; - // 多任务支持 analysisHistory: AnalysisRecord[]; currentRecordId: string | null; - // Phase 2A: 多步骤工作流状态 dataProfile: DataProfile | null; dataProfileLoading: boolean; dataProfileModalVisible: boolean; - workflowPlan: WorkflowPlan | null; workflowPlanLoading: boolean; - workflowSteps: WorkflowStepResult[]; - workflowProgress: number; // 0-100 - conclusionReport: ConclusionReport | null; - isWorkflowMode: boolean; // 是否使用多步骤工作流模式 + // ---- actions ---- setMode: (mode: SSAMode) => void; setCurrentSession: (session: SSASession | null) => void; addMessage: (message: SSAMessage) => void; setMessages: (messages: SSAMessage[]) => void; - setCurrentPlan: (plan: AnalysisPlan | null) => void; - setExecutionResult: (result: ExecutionResult | null) => void; - setTraceSteps: (steps: TraceStep[]) => void; - updateTraceStep: (index: number, step: Partial) => void; setLoading: (loading: boolean) => void; setExecuting: (executing: boolean) => void; setError: (error: string | null) => void; @@ -92,40 +82,29 @@ interface SSAState { setActivePane: (pane: ArtifactPane) => void; setMountedFile: (file: MountedFile | null) => void; setCodeModalVisible: (visible: boolean) => void; - + setSidebarExpanded: (expanded: boolean) => void; setWorkspaceOpen: (open: boolean) => void; addToast: (message: string, type?: 'info' | 'success' | 'error') => void; removeToast: (id: string) => void; hydrateFromHistory: (session: SSASession) => void; - // 多任务操作 - addAnalysisRecord: (query: string, plan: AnalysisPlan) => string; - updateAnalysisRecord: (id: string, update: Partial>) => void; - selectAnalysisRecord: (id: string) => void; - getCurrentRecord: () => AnalysisRecord | null; + // Record operations (unified) + addRecord: (query: string, plan: WorkflowPlan) => string; + updateRecord: (id: string, patch: Partial>) => void; + selectRecord: (id: string) => void; - // Phase 2A: 多步骤工作流操作 + // Data profile setDataProfile: (profile: DataProfile | null) => void; setDataProfileLoading: (loading: boolean) => void; setDataProfileModalVisible: (visible: boolean) => void; - setWorkflowPlan: (plan: WorkflowPlan | null) => void; setWorkflowPlanLoading: (loading: boolean) => void; - setWorkflowSteps: (steps: WorkflowStepResult[]) => void; - updateWorkflowStep: (stepNumber: number, update: Partial) => void; - setWorkflowProgress: (progress: number) => void; - setConclusionReport: (report: ConclusionReport | null) => void; - setIsWorkflowMode: (isWorkflow: boolean) => void; - resetWorkflow: () => void; } const initialState = { mode: 'analysis' as SSAMode, currentSession: null, messages: [], - currentPlan: null, - executionResult: null, - traceSteps: [], isLoading: false, isExecuting: false, error: null, @@ -137,59 +116,29 @@ const initialState = { toasts: [] as Toast[], analysisHistory: [] as AnalysisRecord[], currentRecordId: null as string | null, - // Phase 2A: 多步骤工作流初始状态 dataProfile: null as DataProfile | null, dataProfileLoading: false, dataProfileModalVisible: false, - workflowPlan: null as WorkflowPlan | null, workflowPlanLoading: false, - workflowSteps: [] as WorkflowStepResult[], - workflowProgress: 0, - conclusionReport: null as ConclusionReport | null, - isWorkflowMode: false, }; export const useSSAStore = create((set) => ({ ...initialState, setMode: (mode) => set({ mode }), - setCurrentSession: (session) => set({ currentSession: session }), - addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })), - setMessages: (messages) => set({ messages }), - - setCurrentPlan: (plan) => set({ currentPlan: plan }), - - setExecutionResult: (result) => set({ executionResult: result }), - - setTraceSteps: (steps) => set({ traceSteps: steps }), - - updateTraceStep: (index, step) => - set((state) => ({ - traceSteps: state.traceSteps.map((s, i) => - i === index ? { ...s, ...step } : s - ), - })), - setLoading: (loading) => set({ isLoading: loading }), - setExecuting: (executing) => set({ isExecuting: executing }), - setError: (error) => set({ error }), - reset: () => set(initialState), setActivePane: (pane) => set({ activePane: pane }), - setMountedFile: (file) => set({ mountedFile: file }), - setCodeModalVisible: (visible) => set({ codeModalVisible: visible }), - setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }), - setWorkspaceOpen: (open) => set({ workspaceOpen: open }), addToast: (message, type = 'info') => @@ -206,142 +155,72 @@ export const useSSAStore = create((set) => ({ })), hydrateFromHistory: (session) => { - if (session.executionResult) { - set({ - activePane: 'result', - executionResult: session.executionResult, - currentSession: session, - workspaceOpen: true, - }); - } else if (session.currentPlan) { - set({ - activePane: 'sap', - currentPlan: session.currentPlan, - currentSession: session, - workspaceOpen: true, - }); - } else { - set({ - activePane: 'empty', - currentSession: session, - workspaceOpen: false, - }); - } - + set({ + currentSession: session, + workspaceOpen: false, + activePane: 'empty', + }); if (session.dataSchema) { set({ mountedFile: { name: session.title || 'data.csv', size: 0, rowCount: session.dataSchema.rowCount, - } + }, }); } }, - // 添加新的分析记录 - addAnalysisRecord: (query, plan) => { - const recordId = plan.id || `record_${Date.now()}`; + // ==================== Record operations ==================== + + addRecord: (query, plan) => { + const recordId = plan.workflow_id || `record_${Date.now()}`; const newRecord: AnalysisRecord = { id: recordId, query, createdAt: new Date().toISOString(), + status: 'planning', plan, - executionResult: null, - traceSteps: [], + steps: [], + progress: 0, + conclusionReport: null, }; - set((state) => ({ analysisHistory: [...state.analysisHistory, newRecord], currentRecordId: recordId, - currentPlan: plan, - executionResult: null, - traceSteps: [], })); - return recordId; }, - // 更新分析记录(如执行结果) - updateAnalysisRecord: (id, update) => { - set((state) => { - const updatedHistory = state.analysisHistory.map((record) => - record.id === id ? { ...record, ...update } : record - ); - - // 如果更新的是当前记录,同步更新当前状态 - const isCurrentRecord = state.currentRecordId === id; - return { - analysisHistory: updatedHistory, - ...(isCurrentRecord && update.executionResult !== undefined - ? { executionResult: update.executionResult } - : {}), - ...(isCurrentRecord && update.traceSteps !== undefined - ? { traceSteps: update.traceSteps } - : {}), - }; - }); - }, - - // 选择/切换到某个分析记录 - selectAnalysisRecord: (id) => { - set((state) => { - const record = state.analysisHistory.find((r) => r.id === id); - if (!record) return state; - - return { - currentRecordId: id, - currentPlan: record.plan, - executionResult: record.executionResult, - traceSteps: record.traceSteps, - activePane: record.executionResult ? 'result' : 'sap', - workspaceOpen: true, - }; - }); - }, - - // 获取当前记录(使用 get 方法避免循环引用) - getCurrentRecord: (): AnalysisRecord | null => { - return null; // 此方法在组件中通过直接访问 state 实现 - }, - - // Phase 2A: 多步骤工作流操作 - setDataProfile: (profile) => set({ dataProfile: profile }), - - setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }), - - setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }), - - setWorkflowPlan: (plan) => set({ workflowPlan: plan }), - - setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }), - - setWorkflowSteps: (steps) => set({ workflowSteps: steps }), - - updateWorkflowStep: (stepNumber, update) => + updateRecord: (id, patch) => { set((state) => ({ - workflowSteps: state.workflowSteps.map((s) => - s.step_number === stepNumber ? { ...s, ...update } : s + analysisHistory: state.analysisHistory.map((r) => + r.id === id ? { ...r, ...patch } : r ), - })), + })); + }, - setWorkflowProgress: (progress) => set({ workflowProgress: progress }), - - setConclusionReport: (report) => set({ conclusionReport: report }), - - setIsWorkflowMode: (isWorkflow) => set({ isWorkflowMode: isWorkflow }), - - resetWorkflow: () => + selectRecord: (id) => { set({ - dataProfile: null, - dataProfileLoading: false, - workflowPlan: null, - workflowPlanLoading: false, - workflowSteps: [], - workflowProgress: 0, - conclusionReport: null, - isWorkflowMode: false, - }), + currentRecordId: id, + workspaceOpen: true, + }); + }, + + // Data profile + setDataProfile: (profile) => set({ dataProfile: profile }), + setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }), + setDataProfileModalVisible: (visible) => set({ dataProfileModalVisible: visible }), + setWorkflowPlanLoading: (loading) => set({ workflowPlanLoading: loading }), })); +// ==================== Derived selectors ==================== + +/** Get the currently selected record (call inside component / callback) */ +export function getCurrentRecord(): AnalysisRecord | null { + const { analysisHistory, currentRecordId } = useSSAStore.getState(); + if (!currentRecordId) return null; + return analysisHistory.find((r) => r.id === currentRecordId) ?? null; +} + export default useSSAStore; diff --git a/frontend-v2/src/modules/ssa/styles/ssa-workspace.css b/frontend-v2/src/modules/ssa/styles/ssa-workspace.css index dac2a87c..92e90125 100644 --- a/frontend-v2/src/modules/ssa/styles/ssa-workspace.css +++ b/frontend-v2/src/modules/ssa/styles/ssa-workspace.css @@ -561,6 +561,19 @@ font-size: 14px; font-weight: 700; color: #1e40af; + display: flex; + align-items: center; + gap: 6px; +} + +.sap-card-badge { + font-size: 10px; + font-weight: 600; + color: #059669; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 4px; + padding: 1px 6px; } .sap-card-hint { @@ -2723,238 +2736,213 @@ Phase 2A: 工作流时间线样式 ============================================ */ -.workflow-timeline { - padding: 20px; +/* ============================================ + WorkflowTimeline v2 — wt-* namespace + ============================================ */ + +.wt-root { + padding: 24px 20px; } -.workflow-timeline .timeline-header { +/* --- Header --- */ +.wt-header { + display: flex; + gap: 14px; + align-items: flex-start; margin-bottom: 20px; - padding-bottom: 16px; + padding-bottom: 18px; border-bottom: 1px solid #e2e8f0; } - -.workflow-timeline .header-info { - margin-bottom: 12px; +.wt-header-icon { + width: 40px; + height: 40px; + border-radius: 10px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } - -.workflow-timeline .timeline-title { +.wt-header-body { + flex: 1; + min-width: 0; +} +.wt-title { margin: 0; - font-size: 16px; - font-weight: 600; - color: #1e293b; - line-height: 1.4; + font-size: 17px; + font-weight: 700; + color: #0f172a; + line-height: 1.35; } - -.workflow-timeline .timeline-description { - margin: 8px 0 0 0; +.wt-desc { + margin: 6px 0 0; font-size: 13px; color: #64748b; line-height: 1.6; word-break: break-word; - overflow-wrap: break-word; } - -.workflow-timeline .header-meta { +.wt-meta { display: flex; - gap: 16px; - margin-top: 0; + gap: 14px; + margin-top: 10px; } - -.workflow-timeline .step-count, -.workflow-timeline .estimated-time { +.wt-meta-item { + display: inline-flex; + align-items: center; + gap: 5px; font-size: 12px; color: #94a3b8; - display: flex; - align-items: center; - gap: 4px; + font-weight: 500; } -.workflow-timeline .timeline-progress { +/* --- Banners --- */ +.wt-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + margin-bottom: 14px; + font-size: 12.5px; + border-radius: 8px; + line-height: 1.5; +} +.wt-banner b { + font-weight: 600; +} +.wt-banner-warn { + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; +} +.wt-banner-guard { + color: #1e40af; + background: #eff6ff; + border: 1px solid #bfdbfe; +} + +/* --- Progress --- */ +.wt-progress-bar-wrap { display: flex; align-items: center; gap: 12px; - margin-bottom: 20px; - padding: 12px 16px; - background: #f8fafc; - border-radius: 8px; + margin-bottom: 18px; } - -.workflow-timeline .progress-bar { +.wt-progress-track { flex: 1; - height: 6px; + height: 5px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } - -.workflow-timeline .progress-fill { +.wt-progress-fill { height: 100%; - background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); + background: linear-gradient(90deg, #6366f1, #3b82f6); border-radius: 3px; - transition: width 0.3s ease; + transition: width .4s ease; } - -.workflow-timeline .progress-text { +.wt-progress-label { font-size: 12px; - font-weight: 500; + font-weight: 600; color: #64748b; white-space: nowrap; } -.workflow-timeline .timeline-steps { +/* --- Steps container --- */ +.wt-steps { display: flex; flex-direction: column; } -.workflow-timeline .timeline-step { +/* --- Step row (rail + card) --- */ +.wt-step-row { display: flex; - gap: 16px; - padding-bottom: 20px; + gap: 14px; + min-height: 48px; +} +.wt-step-row.wt-current .wt-card { + box-shadow: 0 0 0 2px #bfdbfe; } -.workflow-timeline .timeline-step.current { - background: #eff6ff; - margin: 0 -20px; - padding: 16px 20px; - border-radius: 8px; -} - -.workflow-timeline .step-connector { +/* Rail */ +.wt-rail { display: flex; flex-direction: column; align-items: center; - width: 24px; + width: 28px; + flex-shrink: 0; } - -.workflow-timeline .step-dot { - width: 24px; - height: 24px; +.wt-dot { + width: 28px; + height: 28px; border-radius: 50%; border: 2px solid; display: flex; align-items: center; justify-content: center; font-size: 12px; - font-weight: 600; + font-weight: 700; flex-shrink: 0; } - -.workflow-timeline .step-dot.pulse { +.wt-dot.running { animation: pulse 1.5s ease-in-out infinite; } - -.workflow-timeline .step-line { +.wt-line { flex: 1; - width: 2px; + width: 0; + min-height: 16px; border-left: 2px dashed #e2e8f0; - margin-top: 4px; + margin: 4px 0; } -.workflow-timeline .step-content { +/* Card */ +.wt-card { flex: 1; min-width: 0; + background: white; + border: 1px solid #e2e8f0; + border-left: 3px solid #e2e8f0; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 12px; + transition: box-shadow .2s, border-color .2s; } - -.workflow-timeline .step-header { +.wt-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,.05); +} +.wt-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.wt-card-title-row { display: flex; align-items: center; gap: 8px; - margin-bottom: 6px; + min-width: 0; } - -.workflow-timeline .step-number { - font-size: 12px; - font-weight: 500; - color: #64748b; +.wt-step-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + color: #6366f1; + background: #eef2ff; + border-radius: 4px; + white-space: nowrap; } - -.workflow-timeline .tool-icon { - font-size: 14px; -} - -.workflow-timeline .tool-name { +.wt-tool-name { font-size: 14px; font-weight: 600; color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - -.workflow-timeline .step-duration { - font-size: 11px; - color: #94a3b8; - margin-left: auto; -} - -.workflow-timeline .step-description { - font-size: 13px; - color: #64748b; - margin-bottom: 8px; -} - -.workflow-timeline .step-params { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.workflow-timeline .param-tag { - padding: 2px 8px; - background: #f1f5f9; - border-radius: 4px; - font-size: 11px; - color: #64748b; - font-family: 'SF Mono', Monaco, monospace; -} - -.workflow-timeline .step-result-preview { - display: flex; - gap: 8px; - margin-top: 8px; -} - -.workflow-timeline .result-badge { - padding: 2px 8px; - background: #f1f5f9; - border-radius: 4px; - font-size: 11px; - color: #64748b; - font-family: 'SF Mono', Monaco, monospace; -} - -.workflow-timeline .significant-badge { - padding: 2px 8px; - background: #ecfdf5; - border-radius: 4px; - font-size: 11px; - color: #059669; - font-weight: 500; -} - -.workflow-timeline .step-error { - display: flex; - align-items: flex-start; - gap: 6px; - margin-top: 8px; - padding: 8px; - background: #fef2f2; - border-radius: 6px; -} - -.workflow-timeline .error-icon { - flex-shrink: 0; -} - -.workflow-timeline .error-message { - font-size: 12px; - color: #dc2626; -} - -.workflow-timeline .timeline-footer { - padding-top: 16px; - text-align: center; -} - -.workflow-timeline .sensitivity-badge { - display: inline-block; +.wt-sensitivity { + display: inline-flex; padding: 1px 8px; font-size: 11px; font-weight: 600; @@ -2962,12 +2950,28 @@ background: #fffbeb; border: 1px solid #fcd34d; border-radius: 10px; - margin-left: 6px; + white-space: nowrap; +} +.wt-duration { + font-size: 11px; + color: #94a3b8; + font-family: 'SF Mono', Monaco, monospace; + white-space: nowrap; } -.workflow-timeline .step-guardrail { - margin-top: 4px; - padding: 4px 8px; +/* Card body */ +.wt-card-desc { + margin: 8px 0 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; +} +.wt-guardrail-inline { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 5px 10px; font-size: 12px; color: #1d4ed8; background: #eff6ff; @@ -2975,45 +2979,83 @@ border-radius: 4px; } -.workflow-timeline .epv-warning-banner { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - margin-bottom: 12px; - font-size: 13px; - color: #92400e; - background: #fffbeb; - border: 1px solid #fcd34d; +/* Params grid */ +.wt-params-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 10px; + margin-top: 10px; + padding: 10px 12px; + background: #f8fafc; + border: 1px solid #f1f5f9; border-radius: 8px; } - -.workflow-timeline .epv-warning-banner .epv-icon { - font-size: 16px; - flex-shrink: 0; +.wt-param-item { + display: contents; } - -.workflow-timeline .guardrail-banner { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - margin-bottom: 12px; - font-size: 13px; - color: #1e40af; - background: #eff6ff; - border: 1px solid #93c5fd; - border-radius: 8px; +.wt-param-label { + font-size: 11px; + font-weight: 600; + color: #475569; + white-space: nowrap; + line-height: 1.8; } - -.workflow-timeline .guardrail-banner .guardrail-icon { - font-size: 16px; - flex-shrink: 0; -} - -.workflow-timeline .ready-hint { - font-size: 13px; +.wt-param-val { + font-size: 11.5px; color: #64748b; + font-family: 'SF Mono', Monaco, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.8; +} + +/* Result / Error */ +.wt-result-row { + display: flex; + gap: 8px; + margin-top: 10px; +} +.wt-p-badge { + padding: 3px 10px; + background: #f1f5f9; + border-radius: 6px; + font-size: 12px; + color: #475569; + font-family: 'SF Mono', Monaco, monospace; + font-weight: 500; +} +.wt-sig-badge { + padding: 3px 10px; + background: #dcfce7; + border-radius: 6px; + font-size: 12px; + color: #15803d; + font-weight: 600; +} +.wt-error-row { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 10px; + padding: 8px 12px; + background: #fef2f2; + border-radius: 6px; + font-size: 12px; + color: #dc2626; + line-height: 1.5; +} + +/* Footer */ +.wt-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px 0 4px; + font-size: 13px; + color: #94a3b8; + font-weight: 500; } /* ============================================ diff --git a/frontend-v2/src/modules/ssa/types/index.ts b/frontend-v2/src/modules/ssa/types/index.ts index 72c92936..47512bf5 100644 --- a/frontend-v2/src/modules/ssa/types/index.ts +++ b/frontend-v2/src/modules/ssa/types/index.ts @@ -83,7 +83,7 @@ export interface AnalysisPlan { dependentVar?: string; } -export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result'; +export type ArtifactType = 'sap' | 'confirm' | 'execution' | 'result' | 'profile'; export interface SSAMessage { id: string; diff --git a/r-statistics-service/plumber.R b/r-statistics-service/plumber.R index 5589f8de..56bffc29 100644 --- a/r-statistics-service/plumber.R +++ b/r-statistics-service/plumber.R @@ -187,6 +187,16 @@ function(req, tool_code) { # 解析请求体 input <- jsonlite::fromJSON(req$postBody, simplifyVector = FALSE) + # 记录传入参数(便于调试) + param_names <- if (!is.null(input$params)) paste(names(input$params), collapse=", ") else "NULL" + message(glue::glue("[Skill:{tool_code}] params keys: [{param_names}]")) + if (!is.null(input$params$variables)) { + message(glue::glue("[Skill:{tool_code}] variables ({length(input$params$variables)}): [{paste(input$params$variables, collapse=', ')}]")) + } + if (!is.null(input$params$group_var)) { + message(glue::glue("[Skill:{tool_code}] group_var: {input$params$group_var}")) + } + # Debug 模式:保留临时文件用于排查 debug_mode <- isTRUE(input$debug) @@ -248,7 +258,7 @@ function(req, tool_code) { return(result) }, error = function(e) { - # 使用友好错误映射 + message(glue::glue("[Skill:{tool_code}] ERROR: {e$message}")) return(map_r_error(e$message)) }) } diff --git a/r-statistics-service/tools/descriptive.R b/r-statistics-service/tools/descriptive.R index 20ec6548..d934df62 100644 --- a/r-statistics-service/tools/descriptive.R +++ b/r-statistics-service/tools/descriptive.R @@ -31,30 +31,43 @@ run_analysis <- function(input) { log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) p <- input$params - variables <- p$variables # 变量列表(可选,空则分析全部) - group_var <- p$group_var # 分组变量(可选) - + variables <- p$variables + group_var <- p$group_var + + # Normalize group_var: ensure it's NULL or a valid non-empty string (never NA) + if (is.null(group_var) || length(group_var) == 0 || isTRUE(is.na(group_var)) || !nzchar(trimws(as.character(group_var[1])))) { + group_var <- NULL + } else { + group_var <- as.character(group_var[1]) + } + + log_add(glue("=== 输入参数 === variables: [{paste(variables, collapse=', ')}], group_var: {ifelse(is.null(group_var), 'NULL', group_var)}")) + log_add(glue("=== 数据列 === [{paste(names(df), collapse=', ')}]")) + # ===== 确定要分析的变量 ===== if (is.null(variables) || length(variables) == 0) { variables <- names(df) log_add("未指定变量,分析全部列") } - + variables <- as.character(variables) + # 排除分组变量本身 if (!is.null(group_var) && group_var %in% variables) { variables <- setdiff(variables, group_var) } - + # 校验变量存在性 missing_vars <- setdiff(variables, names(df)) if (length(missing_vars) > 0) { + log_add(glue("缺失变量: [{paste(missing_vars, collapse=', ')}]")) return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = paste(missing_vars, collapse = ", "))) } - + log_add(glue("最终分析变量 ({length(variables)}): [{paste(variables, collapse=', ')}]")) + # 校验分组变量 groups <- NULL - if (!is.null(group_var) && group_var != "") { + if (!is.null(group_var)) { if (!(group_var %in% names(df))) { return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = group_var)) } @@ -63,25 +76,32 @@ run_analysis <- function(input) { } # ===== 变量类型推断 ===== - var_types <- sapply(variables, function(v) { - vals <- df[[v]] - if (is.numeric(vals)) { - non_na_count <- sum(!is.na(vals)) - if (non_na_count == 0) { - return("categorical") # 全是 NA,当作分类变量 - } - unique_count <- length(unique(vals[!is.na(vals)])) - unique_ratio <- unique_count / non_na_count - if (unique_ratio < 0.05 && unique_count <= 10) { + var_types <- tryCatch({ + result <- sapply(variables, function(v) { + vals <- df[[v]] + if (is.null(vals)) return("categorical") + if (isTRUE(is.numeric(vals))) { + non_na_count <- sum(!is.na(vals)) + if (non_na_count == 0) return("categorical") + unique_count <- length(unique(vals[!is.na(vals)])) + unique_ratio <- unique_count / non_na_count + if (isTRUE(unique_ratio < 0.05) && isTRUE(unique_count <= 10)) { + return("categorical") + } + return("numeric") + } else { return("categorical") } - return("numeric") - } else { - return("categorical") - } + }) + if (is.null(names(result))) names(result) <- variables + result + }, error = function(e) { + log_add(paste("变量类型推断失败:", e$message)) + setNames(rep("categorical", length(variables)), variables) }) - - log_add(glue("数值变量: {sum(var_types == 'numeric')}, 分类变量: {sum(var_types == 'categorical')}")) + + log_add(glue("数值变量: {sum(var_types == 'numeric', na.rm=TRUE)}, 分类变量: {sum(var_types == 'categorical', na.rm=TRUE)}")) + log_add(glue("var_types 详情: {paste(names(var_types), '=', var_types, collapse=', ')}")) # ===== 计算描述性统计 ===== warnings_list <- c() @@ -106,7 +126,8 @@ run_analysis <- function(input) { # 有分组 group_stats <- list() for (g in groups) { - subset_vals <- df[df[[group_var]] == g, v, drop = TRUE] + mask <- df[[group_var]] == g & !is.na(df[[group_var]]) + subset_vals <- df[mask, v, drop = TRUE] if (identical(var_type, "numeric")) { group_stats[[as.character(g)]] <- calc_numeric_stats(subset_vals, v) } else { @@ -145,7 +166,7 @@ run_analysis <- function(input) { for (v in vars_to_plot) { plot_base64 <- tryCatch({ - if (var_types[v] == "numeric") { + if (isTRUE(var_types[v] == "numeric")) { generate_histogram(df, v, group_var) } else { generate_bar_chart(df, v, group_var) @@ -167,6 +188,67 @@ run_analysis <- function(input) { "data.csv" } + # Build dynamic visualization code based on actual variables + plot_code_section <- tryCatch({ + plot_code_lines <- c() + for (v in vars_to_plot) { + safe_v <- gsub('"', '\\\\"', v) + vt <- if (is.null(var_types) || is.na(var_types[v])) "categorical" else as.character(var_types[v]) + safe_var_name <- gsub("[^a-zA-Z0-9]", "_", v) + if (vt == "numeric") { + if (!is.null(group_var) && group_var != "") { + safe_g <- gsub('"', '\\\\"', group_var) + plot_code_lines <- c(plot_code_lines, glue(' +# Histogram: {safe_v} +p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = .data[["{safe_v}"]], fill = factor(.data[["{safe_g}"]]))) + + geom_histogram(alpha = 0.6, position = "identity", bins = 30) + + scale_fill_brewer(palette = "Set1", name = "{safe_g}") + + labs(title = "Distribution of {safe_v}", x = "{safe_v}", y = "Count") + + theme_minimal() +print(p_{safe_var_name}) +')) + } else { + plot_code_lines <- c(plot_code_lines, glue(' +# Histogram: {safe_v} +p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = .data[["{safe_v}"]])) + + geom_histogram(fill = "#3b82f6", alpha = 0.7, bins = 30) + + labs(title = "Distribution of {safe_v}", x = "{safe_v}", y = "Count") + + theme_minimal() +print(p_{safe_var_name}) +')) + } + } else { + if (!is.null(group_var) && group_var != "") { + safe_g <- gsub('"', '\\\\"', group_var) + plot_code_lines <- c(plot_code_lines, glue(' +# Bar chart: {safe_v} +p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = factor(.data[["{safe_v}"]]), fill = factor(.data[["{safe_g}"]]))) + + geom_bar(position = "dodge") + + scale_fill_brewer(palette = "Set1", name = "{safe_g}") + + labs(title = "Frequency of {safe_v}", x = "{safe_v}", y = "Count") + + theme_minimal() + + theme(axis.text.x = element_text(angle = 45, hjust = 1)) +print(p_{safe_var_name}) +')) + } else { + plot_code_lines <- c(plot_code_lines, glue(' +# Bar chart: {safe_v} +p_{safe_var_name} <- ggplot(df[!is.na(df[["{safe_v}"]]), ], aes(x = factor(.data[["{safe_v}"]]))) + + geom_bar(fill = "#3b82f6", alpha = 0.7) + + labs(title = "Frequency of {safe_v}", x = "{safe_v}", y = "Count") + + theme_minimal() + + theme(axis.text.x = element_text(angle = 45, hjust = 1)) +print(p_{safe_var_name}) +')) + } + } + } + paste(plot_code_lines, collapse = "\n") + }, error = function(e) { + log_add(paste("reproducible_code visualization generation failed:", e$message)) + "# ggplot(df, aes(x = your_variable)) + geom_histogram()" + }) + reproducible_code <- glue(' # SSA-Pro 自动生成代码 # 工具: 描述性统计 @@ -181,7 +263,7 @@ df <- read.csv("{original_filename}") # 数值变量描述性统计 numeric_vars <- sapply(df, is.numeric) if (any(numeric_vars)) {{ - summary(df[, numeric_vars, drop = FALSE]) + print(summary(df[, numeric_vars, drop = FALSE])) }} # 分类变量频数表 @@ -193,8 +275,8 @@ if (any(categorical_vars)) {{ }} }} -# 可视化示例 -# ggplot(df, aes(x = your_variable)) + geom_histogram() +# ======== 可视化 ======== +{plot_code_section} ') # ===== 返回结果 =====