From 428a22adf284ffada8d6648cf9886267cd215900 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Fri, 20 Feb 2026 23:09:27 +0800 Subject: [PATCH] feat(ssa): Complete Phase 2A frontend integration - multi-step workflow end-to-end Phase 2A: WorkflowPlannerService, WorkflowExecutorService, Python data quality, 6 bug fixes, DescriptiveResultView, multi-step R code/Word export, MVP UI reuse. V11 UI: Gemini-style, multi-task, single-page scroll, Word export. Architecture: Block-based rendering consensus (4 block types). New R tools: chi_square, correlation, descriptive, logistic_binary, mann_whitney, t_test_paired. Docs: dev summary, block-based plan, status updates, task list v2.0. Co-authored-by: Cursor --- .../20260220_add_ssa_workflow_tables.sql | 100 + backend/prisma/schema.prisma | 53 + backend/src/modules/ssa/index.ts | 3 + .../src/modules/ssa/routes/workflow.routes.ts | 430 +++++ .../services/ConclusionGeneratorService.ts | 369 ++++ .../ssa/services/DataProfileService.ts | 353 ++++ .../ssa/services/WorkflowExecutorService.ts | 521 ++++++ .../ssa/services/WorkflowPlannerService.ts | 603 +++++++ .../00-系统当前状态与开发指南.md | 49 +- .../06-R统计引擎/01-R统计引擎架构与部署指南.md | 369 +++- .../SSA-智能统计分析/00-模块当前状态与开发指南.md | 48 +- .../00-系统设计/SSA-Pro 愿景与开发计划对比分析.md | 447 +++++ .../SSA-智能统计分析/03-UI设计/V12.html | 546 ++++++ .../04-开发计划/00-MVP开发计划总览.md | 63 +- .../04-开发计划/01-任务清单与进度追踪.md | 100 +- .../04-开发计划/02-R服务开发指南.md | 48 +- .../04-开发计划/03-后端开发指南.md | 554 +++++- .../04-开发计划/04-前端开发指南.md | 47 +- .../04-开发计划/06-智能化演进共识与MVP执行计划.md | 426 +++++ .../04-开发计划/07-Phase2A-智能化核心开发计划.md | 755 ++++++++ .../04-开发计划/08-Block-based动态结果渲染开发计划.md | 153 ++ .../05-测试文档/phase2a_e2e_test.ts | 581 ++++++ .../SSA-智能统计分析/05-测试文档/run_e2e_test.js | 536 ++++++ .../SSA-智能统计分析/05-测试文档/test.csv | 312 ++++ .../2026-02-20-Phase2A-前端集成与多步骤工作流开发总结.md | 187 ++ .../06-开发记录/SSA-Pro MVP 智能化增强指南.md | 94 + .../06-开发记录/SSA-Pro Prompt体系与专家配置边界梳理.md | 95 + .../06-开发记录/SSA-Pro 动态结果渲染与通信协议规范.md | 172 ++ .../06-开发记录/SSA-Pro 智能化演进路径评估报告.md | 68 + .../06-开发记录/SSA-Pro 智能化演进阶梯.md | 58 + .../06-开发记录/SSA-Pro 架构审查反馈与智能化路径讨论.md | 544 ++++++ .../06-开发记录/架构审查报告:Phase 2A 核心开发计划 .md | 76 + .../06-开发记录/架构审查报告:SSA-Pro 愿景与落地策略.md | 80 + .../06-开发记录/终极架构共识与智能化演进备忘录 (1).md | 140 ++ extraction_service/main.py | 140 ++ extraction_service/operations/data_profile.py | 293 +++ frontend-v2/src/modules/ssa/SSAWorkspace.tsx | 4 + .../ssa/components/ConclusionReport.tsx | 233 +++ .../ssa/components/DataProfileCard.tsx | 145 ++ .../ssa/components/DataProfileModal.tsx | 284 +++ .../modules/ssa/components/SSAChatPane.tsx | 101 +- .../modules/ssa/components/SSACodeModal.tsx | 50 +- .../ssa/components/SSAWorkspacePane.tsx | 734 ++++++-- .../ssa/components/StepProgressCard.tsx | 163 ++ .../ssa/components/WorkflowTimeline.tsx | 184 ++ .../src/modules/ssa/components/index.ts | 7 + frontend-v2/src/modules/ssa/hooks/index.ts | 1 + .../src/modules/ssa/hooks/useAnalysis.ts | 294 +++ .../src/modules/ssa/hooks/useWorkflow.ts | 376 ++++ .../src/modules/ssa/stores/ssaStore.ts | 76 + .../src/modules/ssa/styles/ssa-workspace.css | 1602 ++++++++++++++++- frontend-v2/src/modules/ssa/types/index.ts | 159 ++ r-statistics-service/docker-compose.yml | 1 + r-statistics-service/plumber.R | 51 + r-statistics-service/tools/chi_square.R | 254 +++ r-statistics-service/tools/correlation.R | 242 +++ r-statistics-service/tools/descriptive.R | 332 ++++ r-statistics-service/tools/logistic_binary.R | 316 ++++ r-statistics-service/tools/mann_whitney.R | 235 +++ r-statistics-service/tools/t_test_paired.R | 274 +++ r-statistics-service/utils/data_loader.R | 58 +- r-statistics-service/utils/guardrails.R | 126 ++ 62 files changed, 15416 insertions(+), 299 deletions(-) create mode 100644 backend/prisma/migrations/manual/20260220_add_ssa_workflow_tables.sql create mode 100644 backend/src/modules/ssa/routes/workflow.routes.ts create mode 100644 backend/src/modules/ssa/services/ConclusionGeneratorService.ts create mode 100644 backend/src/modules/ssa/services/DataProfileService.ts create mode 100644 backend/src/modules/ssa/services/WorkflowExecutorService.ts create mode 100644 backend/src/modules/ssa/services/WorkflowPlannerService.ts create mode 100644 docs/03-业务模块/SSA-智能统计分析/00-系统设计/SSA-Pro 愿景与开发计划对比分析.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/03-UI设计/V12.html create mode 100644 docs/03-业务模块/SSA-智能统计分析/04-开发计划/06-智能化演进共识与MVP执行计划.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/04-开发计划/07-Phase2A-智能化核心开发计划.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/04-开发计划/08-Block-based动态结果渲染开发计划.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/05-测试文档/phase2a_e2e_test.ts create mode 100644 docs/03-业务模块/SSA-智能统计分析/05-测试文档/run_e2e_test.js create mode 100644 docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-20-Phase2A-前端集成与多步骤工作流开发总结.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro MVP 智能化增强指南.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro Prompt体系与专家配置边界梳理.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 动态结果渲染与通信协议规范.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进路径评估报告.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进阶梯.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 架构审查反馈与智能化路径讨论.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:Phase 2A 核心开发计划 .md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:SSA-Pro 愿景与落地策略.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/终极架构共识与智能化演进备忘录 (1).md create mode 100644 extraction_service/operations/data_profile.py create mode 100644 frontend-v2/src/modules/ssa/components/ConclusionReport.tsx create mode 100644 frontend-v2/src/modules/ssa/components/DataProfileCard.tsx create mode 100644 frontend-v2/src/modules/ssa/components/DataProfileModal.tsx create mode 100644 frontend-v2/src/modules/ssa/components/StepProgressCard.tsx create mode 100644 frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx create mode 100644 frontend-v2/src/modules/ssa/hooks/useWorkflow.ts create mode 100644 r-statistics-service/tools/chi_square.R create mode 100644 r-statistics-service/tools/correlation.R create mode 100644 r-statistics-service/tools/descriptive.R create mode 100644 r-statistics-service/tools/logistic_binary.R create mode 100644 r-statistics-service/tools/mann_whitney.R create mode 100644 r-statistics-service/tools/t_test_paired.R diff --git a/backend/prisma/migrations/manual/20260220_add_ssa_workflow_tables.sql b/backend/prisma/migrations/manual/20260220_add_ssa_workflow_tables.sql new file mode 100644 index 00000000..18b74d18 --- /dev/null +++ b/backend/prisma/migrations/manual/20260220_add_ssa_workflow_tables.sql @@ -0,0 +1,100 @@ +-- ===================================================== +-- Phase 2A: SSA 智能化核心 - 数据库迁移脚本 +-- 日期: 2026-02-20 +-- 描述: 添加工作流表和数据画像字段 +-- 注意: ssa_sessions.id 是 TEXT 类型(存储 UUID 字符串) +-- ===================================================== + +-- 1. 给 ssa_sessions 表添加 data_profile 字段(如果不存在) +ALTER TABLE ssa_schema.ssa_sessions +ADD COLUMN IF NOT EXISTS data_profile JSONB; + +COMMENT ON COLUMN ssa_schema.ssa_sessions.data_profile IS 'Python Tool C 生成的数据画像 (Phase 2A)'; + +-- 2. 创建 ssa_workflows 表(多步骤分析流程) +CREATE TABLE IF NOT EXISTS ssa_schema.ssa_workflows ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::TEXT, + session_id TEXT NOT NULL, + message_id TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + total_steps INTEGER NOT NULL, + completed_steps INTEGER NOT NULL DEFAULT 0, + workflow_plan JSONB NOT NULL, + reasoning TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + started_at TIMESTAMP WITHOUT TIME ZONE, + completed_at TIMESTAMP WITHOUT TIME ZONE, + + CONSTRAINT fk_ssa_workflow_session + FOREIGN KEY (session_id) + REFERENCES ssa_schema.ssa_sessions(id) + ON DELETE CASCADE +); + +-- ssa_workflows 索引 +CREATE INDEX IF NOT EXISTS idx_ssa_workflow_session + ON ssa_schema.ssa_workflows(session_id); +CREATE INDEX IF NOT EXISTS idx_ssa_workflow_status + ON ssa_schema.ssa_workflows(status); + +-- ssa_workflows 字段注释 +COMMENT ON TABLE ssa_schema.ssa_workflows IS 'SSA 多步骤分析工作流 (Phase 2A)'; +COMMENT ON COLUMN ssa_schema.ssa_workflows.status IS 'pending | running | completed | partial | error'; +COMMENT ON COLUMN ssa_schema.ssa_workflows.workflow_plan IS 'LLM 生成的原始工作流计划 JSON'; +COMMENT ON COLUMN ssa_schema.ssa_workflows.reasoning IS 'LLM 规划理由说明'; + +-- 3. 创建 ssa_workflow_steps 表(流程中的每个步骤) +CREATE TABLE IF NOT EXISTS ssa_schema.ssa_workflow_steps ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::TEXT, + workflow_id TEXT NOT NULL, + step_order INTEGER NOT NULL, + tool_code VARCHAR(50) NOT NULL, + tool_name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + input_params JSONB, + guardrail_checks JSONB, + output_result JSONB, + error_info JSONB, + execution_ms INTEGER, + started_at TIMESTAMP WITHOUT TIME ZONE, + completed_at TIMESTAMP WITHOUT TIME ZONE, + + CONSTRAINT fk_ssa_workflow_step_workflow + FOREIGN KEY (workflow_id) + REFERENCES ssa_schema.ssa_workflows(id) + ON DELETE CASCADE +); + +-- ssa_workflow_steps 索引 +CREATE INDEX IF NOT EXISTS idx_ssa_workflow_step_workflow + ON ssa_schema.ssa_workflow_steps(workflow_id); +CREATE INDEX IF NOT EXISTS idx_ssa_workflow_step_status + ON ssa_schema.ssa_workflow_steps(status); + +-- ssa_workflow_steps 字段注释 +COMMENT ON TABLE ssa_schema.ssa_workflow_steps IS 'SSA 工作流单步执行记录 (Phase 2A)'; +COMMENT ON COLUMN ssa_schema.ssa_workflow_steps.status IS 'pending | running | success | warning | error | skipped'; +COMMENT ON COLUMN ssa_schema.ssa_workflow_steps.guardrail_checks IS 'R Service JIT 护栏检验结果 (正态性、方差齐性等)'; +COMMENT ON COLUMN ssa_schema.ssa_workflow_steps.output_result IS '工具执行结果 (已裁剪,符合 LLM 上下文限制)'; +COMMENT ON COLUMN ssa_schema.ssa_workflow_steps.error_info IS '错误信息 (用于容错管道的部分成功场景)'; + +-- ===================================================== +-- 验证脚本 +-- ===================================================== +SELECT 'ssa_sessions.data_profile 字段' as item, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'ssa_schema' AND table_name = 'ssa_sessions' AND column_name = 'data_profile' + ) THEN '✅ 已创建' ELSE '❌ 未创建' END as status; + +SELECT 'ssa_workflows 表' as item, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'ssa_schema' AND table_name = 'ssa_workflows' + ) THEN '✅ 已创建' ELSE '❌ 未创建' END as status; + +SELECT 'ssa_workflow_steps 表' as item, + CASE WHEN EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'ssa_schema' AND table_name = 'ssa_workflow_steps' + ) THEN '✅ 已创建' ELSE '❌ 未创建' END as status; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 47d5cd82..da98e91d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -2153,12 +2153,14 @@ model SsaSession { dataSchema Json? @map("data_schema") /// 数据结构(LLM可见) dataPayload Json? @map("data_payload") /// 真实数据(仅R可见) dataOssKey String? @map("data_oss_key") /// OSS 存储 key(大数据) + dataProfile Json? @map("data_profile") /// 🆕 Python 生成的 DataProfile(Phase 2A) status String @default("active") /// active | consult | completed | error createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") messages SsaMessage[] executionLogs SsaExecutionLog[] + workflows SsaWorkflow[] /// 🆕 多步骤流程(Phase 2A) @@index([userId], map: "idx_ssa_session_user") @@index([status], map: "idx_ssa_session_status") @@ -2306,3 +2308,54 @@ model SsaInterpretation { @@map("ssa_interpretation_templates") @@schema("ssa_schema") } + +// ============================================================ +// 🆕 Phase 2A 新增:多步骤流程管理 +// ============================================================ + +/// SSA 多步骤流程 +model SsaWorkflow { + id String @id @default(uuid()) + sessionId String @map("session_id") + messageId String? @map("message_id") /// 关联的计划消息 + status String @default("pending") /// pending | running | completed | partial | error + totalSteps Int @map("total_steps") + completedSteps Int @default(0) @map("completed_steps") + workflowPlan Json @map("workflow_plan") /// 原始计划 JSON + reasoning String? @db.Text /// LLM 规划理由 + createdAt DateTime @default(now()) @map("created_at") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + + session SsaSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + steps SsaWorkflowStep[] + + @@index([sessionId], map: "idx_ssa_workflow_session") + @@index([status], map: "idx_ssa_workflow_status") + @@map("ssa_workflows") + @@schema("ssa_schema") +} + +/// SSA 流程步骤 +model SsaWorkflowStep { + id String @id @default(uuid()) + workflowId String @map("workflow_id") + stepOrder Int @map("step_order") /// 步骤顺序(1, 2, 3...) + toolCode String @map("tool_code") + toolName String @map("tool_name") + status String @default("pending") /// pending | running | success | warning | error | skipped + inputParams Json? @map("input_params") /// 输入参数 + guardrailChecks Json? @map("guardrail_checks") /// JIT 护栏检验结果 + outputResult Json? @map("output_result") /// 执行结果 + errorInfo Json? @map("error_info") /// 错误信息 + executionMs Int? @map("execution_ms") /// 执行耗时(毫秒) + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + + workflow SsaWorkflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + + @@index([workflowId], map: "idx_ssa_workflow_step_workflow") + @@index([status], map: "idx_ssa_workflow_step_status") + @@map("ssa_workflow_steps") + @@schema("ssa_schema") +} diff --git a/backend/src/modules/ssa/index.ts b/backend/src/modules/ssa/index.ts index 7cf48994..3f24f93a 100644 --- a/backend/src/modules/ssa/index.ts +++ b/backend/src/modules/ssa/index.ts @@ -13,6 +13,7 @@ import sessionRoutes from './routes/session.routes.js'; import analysisRoutes from './routes/analysis.routes.js'; import consultRoutes from './routes/consult.routes.js'; import configRoutes from './routes/config.routes.js'; +import workflowRoutes from './routes/workflow.routes.js'; export async function ssaRoutes(app: FastifyInstance) { // 注册认证中间件(遵循模块认证规范) @@ -23,6 +24,8 @@ export async function ssaRoutes(app: FastifyInstance) { app.register(analysisRoutes, { prefix: '/sessions' }); app.register(consultRoutes, { prefix: '/consult' }); app.register(configRoutes, { prefix: '/config' }); + // Phase 2A: 多步骤工作流 + app.register(workflowRoutes, { prefix: '/workflow' }); } export default ssaRoutes; diff --git a/backend/src/modules/ssa/routes/workflow.routes.ts b/backend/src/modules/ssa/routes/workflow.routes.ts new file mode 100644 index 00000000..8c49b532 --- /dev/null +++ b/backend/src/modules/ssa/routes/workflow.routes.ts @@ -0,0 +1,430 @@ +/** + * SSA Workflow Routes (Phase 2A) + * + * 多步骤工作流 API: + * - POST /plan - 生成工作流计划 + * - POST /:workflowId/execute - 执行工作流 + * - GET /:workflowId/status - 获取执行状态 + * - GET /:workflowId/stream - SSE 实时进度 + */ + +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { logger } from '../../../common/logging/index.js'; +import { workflowPlannerService } from '../services/WorkflowPlannerService.js'; +import { workflowExecutorService } from '../services/WorkflowExecutorService.js'; +import { dataProfileService } from '../services/DataProfileService.js'; + +// 请求类型定义 +interface PlanWorkflowBody { + sessionId: string; + userQuery: string; +} + +interface ExecuteWorkflowParams { + workflowId: string; +} + +interface WorkflowStatusParams { + workflowId: string; +} + +interface GenerateProfileBody { + sessionId: string; +} + +export default async function workflowRoutes(app: FastifyInstance) { + + /** + * POST /workflow/plan + * 生成多步骤工作流计划 + */ + app.post<{ Body: PlanWorkflowBody }>( + '/plan', + async (request, reply) => { + const { sessionId, userQuery } = request.body; + + if (!sessionId || !userQuery) { + return reply.status(400).send({ + success: false, + error: 'sessionId and userQuery are required' + }); + } + + try { + logger.info('[SSA:API] Planning workflow', { sessionId, userQuery }); + + const plan = await workflowPlannerService.planWorkflow(sessionId, userQuery); + + return reply.send({ + success: true, + plan + }); + + } catch (error: any) { + logger.error('[SSA:API] Workflow planning failed', { + sessionId, + error: error.message + }); + + return reply.status(500).send({ + success: false, + error: error.message + }); + } + } + ); + + /** + * POST /workflow/:workflowId/execute + * 执行工作流 + */ + app.post<{ Params: ExecuteWorkflowParams; Body: { sessionId: string } }>( + '/:workflowId/execute', + async (request, reply) => { + const { workflowId } = request.params; + const { sessionId } = request.body; + + if (!sessionId) { + return reply.status(400).send({ + success: false, + error: 'sessionId is required' + }); + } + + try { + logger.info('[SSA:API] Executing workflow', { workflowId, sessionId }); + + const result = await workflowExecutorService.executeWorkflow(workflowId, sessionId); + + return reply.send({ + success: true, + result + }); + + } catch (error: any) { + logger.error('[SSA:API] Workflow execution failed', { + workflowId, + error: error.message + }); + + return reply.status(500).send({ + success: false, + error: error.message + }); + } + } + ); + + /** + * GET /workflow/:workflowId/status + * 获取工作流状态 + */ + app.get<{ Params: WorkflowStatusParams }>( + '/:workflowId/status', + async (request, reply) => { + const { workflowId } = request.params; + + try { + const status = await workflowExecutorService.getWorkflowStatus(workflowId); + + if (!status) { + return reply.status(404).send({ + success: false, + error: 'Workflow not found' + }); + } + + return reply.send({ + success: true, + workflow: status + }); + + } catch (error: any) { + logger.error('[SSA:API] Get workflow status failed', { + workflowId, + error: error.message + }); + + return reply.status(500).send({ + success: false, + error: error.message + }); + } + } + ); + + /** + * GET /workflow/:workflowId/stream + * SSE 实时进度流 - 连接后自动开始执行 + */ + app.get<{ Params: WorkflowStatusParams }>( + '/:workflowId/stream', + async (request, reply) => { + const { workflowId } = request.params; + + logger.info('[SSA:SSE] Stream connected', { workflowId }); + + // 设置 SSE 响应头 + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*' + }); + + // 发送初始连接确认 + reply.raw.write(`data: ${JSON.stringify({ type: 'connected', workflowId })}\n\n`); + + // 发送心跳 + const heartbeat = setInterval(() => { + reply.raw.write(':heartbeat\n\n'); + }, 15000); + + let isCompleted = false; + + // 监听进度事件 + const onProgress = (message: any) => { + // 添加 workflowId 到消息中 + const enrichedMessage = { ...message, workflowId }; + reply.raw.write(`data: ${JSON.stringify(enrichedMessage)}\n\n`); + + // 如果工作流完成,标记并清理 + if (message.type === 'workflow_complete' || message.type === 'workflow_error') { + isCompleted = true; + cleanup(); + } + }; + + workflowExecutorService.on('progress', onProgress); + + // 清理函数 + const cleanup = () => { + clearInterval(heartbeat); + workflowExecutorService.off('progress', onProgress); + if (!isCompleted) { + reply.raw.write(`data: ${JSON.stringify({ type: 'disconnected' })}\n\n`); + } + reply.raw.end(); + }; + + // 客户端断开连接时清理 + request.raw.on('close', cleanup); + + // 获取 workflow 的 session_id 并启动执行 + try { + const workflow = await import('../../../config/database.js').then(m => + m.prisma.ssaWorkflow.findUnique({ + where: { id: workflowId }, + select: { sessionId: true, status: true } + }) + ); + + if (!workflow) { + reply.raw.write(`data: ${JSON.stringify({ + type: 'workflow_error', + error: 'Workflow not found', + workflowId + })}\n\n`); + cleanup(); + return; + } + + // 如果已完成,直接返回状态 + if (workflow.status === 'completed' || workflow.status === 'failed') { + reply.raw.write(`data: ${JSON.stringify({ + type: 'workflow_complete', + status: workflow.status, + workflowId + })}\n\n`); + cleanup(); + return; + } + + // 异步启动执行(不阻塞 SSE 连接) + logger.info('[SSA:SSE] Starting workflow execution', { workflowId, sessionId: workflow.sessionId }); + workflowExecutorService.executeWorkflow(workflowId, workflow.sessionId) + .catch((error: any) => { + logger.error('[SSA:SSE] Workflow execution failed', { workflowId, error: error.message }); + reply.raw.write(`data: ${JSON.stringify({ + type: 'workflow_error', + error: error.message, + workflowId + })}\n\n`); + cleanup(); + }); + + } catch (error: any) { + logger.error('[SSA:SSE] Failed to start workflow', { workflowId, error: error.message }); + reply.raw.write(`data: ${JSON.stringify({ + type: 'workflow_error', + error: error.message, + workflowId + })}\n\n`); + cleanup(); + } + } + ); + + /** + * POST /workflow/profile + * 生成数据画像 + */ + app.post<{ Body: GenerateProfileBody }>( + '/profile', + async (request, reply) => { + const { sessionId } = request.body; + + if (!sessionId) { + return reply.status(400).send({ + success: false, + error: 'sessionId is required' + }); + } + + try { + logger.info('[SSA:API] Generating data profile', { sessionId }); + + const result = await dataProfileService.generateProfileFromSession(sessionId); + + if (!result.success || !result.profile) { + // 如果画像生成失败,返回基于 session schema 的简化版本 + const session = await import('../../../config/database.js').then(m => m.prisma.ssaSession.findUnique({ + where: { id: sessionId } + })); + + if (session?.dataSchema) { + const schema = session.dataSchema as any; + const fallbackProfile = generateFallbackProfile(schema, session.title || 'data.csv'); + + return reply.send({ + success: true, + profile: fallbackProfile + }); + } + + return reply.send({ + success: false, + error: result.error || 'Profile generation failed' + }); + } + + // 转换为前端期望的格式 + const frontendProfile = convertToFrontendFormat(result.profile, result.quality); + + return reply.send({ + success: true, + profile: frontendProfile + }); + + } catch (error: any) { + logger.error('[SSA:API] Profile generation failed', { + sessionId, + error: error.message + }); + + return reply.status(500).send({ + success: false, + error: error.message + }); + } + } + ); +} + +/** + * 将后端 DataProfile 转换为前端期望的格式 + */ +function convertToFrontendFormat(profile: any, quality?: any) { + const summary = profile.summary || {}; + const columns = profile.columns || []; + + return { + file_name: 'data.csv', + row_count: summary.totalRows || 0, + column_count: summary.totalColumns || 0, + total_cells: (summary.totalRows || 0) * (summary.totalColumns || 0), + missing_cells: summary.totalMissingCells || 0, + missing_ratio: (summary.overallMissingRate || 0) / 100, + duplicate_rows: 0, + duplicate_ratio: 0, + numeric_columns: summary.numericColumns || 0, + categorical_columns: summary.categoricalColumns || 0, + datetime_columns: summary.datetimeColumns || 0, + quality_score: quality?.score || 85, + quality_grade: quality?.grade || 'B', + columns: columns.map((col: any) => ({ + name: col.name, + dtype: col.type, + inferred_type: col.type, + non_null_count: col.totalCount - (col.missingCount || 0), + null_count: col.missingCount || 0, + null_ratio: (col.missingRate || 0) / 100, + unique_count: col.uniqueCount || 0, + unique_ratio: col.uniqueCount ? col.uniqueCount / col.totalCount : 0, + sample_values: col.topValues?.slice(0, 5).map((v: any) => v.value) || [], + mean: col.mean, + std: col.std, + min: col.min, + max: col.max, + median: col.median, + q1: col.q1, + q3: col.q3, + skewness: col.skewness, + kurtosis: col.kurtosis, + outlier_count: col.outlierCount, + outlier_ratio: col.outlierRate, + top_categories: col.topValues?.map((v: any) => ({ + value: v.value, + count: v.count, + ratio: v.percentage / 100 + })) + })), + warnings: quality?.issues || [], + recommendations: quality?.recommendations || [], + generated_at: new Date().toISOString() + }; +} + +/** + * 基于 Schema 生成简化版 fallback profile + */ +function generateFallbackProfile(schema: any, fileName: string) { + const columns = schema.columns || []; + const rowCount = schema.rowCount || 0; + + const numericCols = columns.filter((c: any) => c.type === 'numeric'); + const categoricalCols = columns.filter((c: any) => c.type === 'categorical'); + + const totalMissing = columns.reduce((sum: number, c: any) => sum + (c.nullCount || 0), 0); + const totalCells = rowCount * columns.length; + + return { + file_name: fileName, + row_count: rowCount, + column_count: columns.length, + total_cells: totalCells, + missing_cells: totalMissing, + missing_ratio: totalCells > 0 ? totalMissing / totalCells : 0, + duplicate_rows: 0, + duplicate_ratio: 0, + numeric_columns: numericCols.length, + categorical_columns: categoricalCols.length, + datetime_columns: 0, + quality_score: 80, + quality_grade: 'B', + columns: columns.map((col: any) => ({ + name: col.name, + dtype: col.type, + inferred_type: col.type, + non_null_count: rowCount - (col.nullCount || 0), + null_count: col.nullCount || 0, + null_ratio: rowCount > 0 ? (col.nullCount || 0) / rowCount : 0, + unique_count: col.uniqueValues || 0, + unique_ratio: rowCount > 0 ? (col.uniqueValues || 0) / rowCount : 0, + sample_values: [] + })), + warnings: totalMissing > 0 ? [`数据中存在 ${totalMissing} 个缺失值`] : [], + recommendations: ['建议检查数据完整性后再进行分析'], + generated_at: new Date().toISOString() + }; +} diff --git a/backend/src/modules/ssa/services/ConclusionGeneratorService.ts b/backend/src/modules/ssa/services/ConclusionGeneratorService.ts new file mode 100644 index 00000000..a0cd733b --- /dev/null +++ b/backend/src/modules/ssa/services/ConclusionGeneratorService.ts @@ -0,0 +1,369 @@ +/** + * SSA Conclusion Generator Service (Phase 2A) + * + * 结论生成器:整合多步骤分析结果,生成论文级结论 + * + * 功能: + * - 多步骤结果整合 + * - 论文级结论模板 + * - 方法学说明 + 局限性声明 + */ + +import { logger } from '../../../common/logging/index.js'; +import { StepResult } from './WorkflowExecutorService.js'; + +// 结论报告结构 +export interface ConclusionReport { + title: string; + summary: string; + sections: ConclusionSection[]; + methodology: string; + limitations: string[]; + references?: string[]; +} + +export interface ConclusionSection { + stepOrder: number; + toolName: string; + finding: string; + interpretation: string; + significance: 'significant' | 'not_significant' | 'marginal' | 'na'; + details?: Record; +} + +export class ConclusionGeneratorService { + + /** + * 生成综合结论报告 + * + * @param results 各步骤执行结果 + * @param goal 分析目标 + * @returns 结论报告 + */ + generateConclusion(results: StepResult[], goal: string): ConclusionReport { + logger.info('[SSA:Conclusion] Generating conclusion', { + stepCount: results.length, + goal + }); + + const sections: ConclusionSection[] = []; + const successResults = results.filter(r => r.status === 'success' || r.status === 'warning'); + + for (const result of successResults) { + const section = this.generateSectionConclusion(result); + if (section) { + sections.push(section); + } + } + + const summary = this.generateSummary(sections, goal); + const methodology = this.generateMethodology(results); + const limitations = this.generateLimitations(results); + + const report: ConclusionReport = { + title: `统计分析报告:${goal}`, + summary, + sections, + methodology, + limitations + }; + + logger.info('[SSA:Conclusion] Conclusion generated', { + sectionCount: sections.length, + hasLimitations: limitations.length > 0 + }); + + return report; + } + + /** + * 生成单步骤结论 + */ + private generateSectionConclusion(result: StepResult): ConclusionSection | null { + if (!result.result) { + return null; + } + + const { toolCode, toolName, stepOrder } = result; + const data = result.result; + + let finding = ''; + let interpretation = ''; + let significance: ConclusionSection['significance'] = 'na'; + + switch (toolCode) { + case 'ST_DESCRIPTIVE': + finding = this.formatDescriptiveFindings(data); + interpretation = '上述数据展示了研究样本的基本特征分布。'; + break; + + case 'ST_T_TEST_IND': + case 'ST_MANN_WHITNEY': + const pValue = data.p_value; + significance = this.interpretPValue(pValue); + finding = this.formatComparisonFindings(data, toolCode); + interpretation = this.interpretComparison(data, significance); + break; + + case 'ST_T_TEST_PAIRED': + const pairedP = data.p_value; + significance = this.interpretPValue(pairedP); + finding = this.formatPairedFindings(data); + interpretation = this.interpretPairedResult(data, significance); + break; + + case 'ST_CHI_SQUARE': + const chiP = data.p_value; + significance = this.interpretPValue(chiP); + finding = this.formatChiSquareFindings(data); + interpretation = this.interpretChiSquare(data, significance); + break; + + case 'ST_CORRELATION': + const corrP = data.p_value; + significance = this.interpretPValue(corrP); + finding = this.formatCorrelationFindings(data); + interpretation = this.interpretCorrelation(data, significance); + break; + + case 'ST_LOGISTIC_BINARY': + finding = this.formatLogisticFindings(data); + interpretation = this.interpretLogistic(data); + significance = 'na'; + break; + + default: + finding = `${toolName} 分析已完成。`; + interpretation = '请参考详细结果解读。'; + } + + return { + stepOrder, + toolName, + finding, + interpretation, + significance, + details: { + pValue: data.p_value, + pValueFmt: data.p_value_fmt + } + }; + } + + /** + * 生成总结 + */ + private generateSummary(sections: ConclusionSection[], goal: string): string { + const significantFindings = sections.filter(s => s.significance === 'significant'); + const marginalFindings = sections.filter(s => s.significance === 'marginal'); + + let summary = `针对「${goal}」进行了 ${sections.length} 项统计分析。`; + + if (significantFindings.length > 0) { + summary += `\n\n主要发现:${significantFindings.length} 项分析达到统计学显著性(p < 0.05)。`; + for (const finding of significantFindings) { + summary += `\n- ${finding.toolName}:${finding.interpretation}`; + } + } + + if (marginalFindings.length > 0) { + summary += `\n\n边缘性发现:${marginalFindings.length} 项分析接近显著水平(0.05 ≤ p < 0.10)。`; + } + + if (significantFindings.length === 0) { + summary += '\n\n本次分析未发现具有统计学显著性的差异或关联。'; + } + + return summary; + } + + /** + * 生成方法学说明 + */ + private generateMethodology(results: StepResult[]): string { + const methods: string[] = []; + + for (const result of results) { + if (result.result?.method) { + methods.push(result.result.method); + } + } + + let methodology = '本研究采用以下统计方法进行分析:\n'; + + const uniqueMethods = [...new Set(methods)]; + for (const method of uniqueMethods) { + methodology += `- ${method}\n`; + } + + methodology += '\n所有分析均在执行前进行了统计假设检验(正态性、方差齐性等),并根据检验结果自动选择适当的统计方法。'; + methodology += '\n显著性水平设定为 α = 0.05(双侧检验)。'; + + return methodology; + } + + /** + * 生成局限性声明 + */ + private generateLimitations(results: StepResult[]): string[] { + const limitations: string[] = []; + + // 检查样本量 + for (const result of results) { + if (result.result?.group_stats) { + const minN = Math.min(...result.result.group_stats.map((g: any) => g.n || 0)); + if (minN < 30) { + limitations.push(`部分分析的样本量较小(n < 30),可能影响结果的稳健性。`); + break; + } + } + } + + // 检查警告 + const warnings = results.flatMap(r => r.result?.warnings || []); + if (warnings.length > 0) { + limitations.push(`分析过程中存在统计警告,请谨慎解读结果。`); + } + + // 通用局限性 + limitations.push('本分析基于横断面数据,无法推断因果关系。'); + limitations.push('未考虑潜在的混杂因素,结果可能存在偏倚。'); + + return limitations; + } + + // ==================== 格式化辅助函数 ==================== + + private formatDescriptiveFindings(data: any): string { + const summary = data.summary; + if (!summary) return '描述性统计已完成。'; + + return `样本包含 ${summary.n_total || '?'} 个观测值,` + + `${summary.n_numeric || 0} 个数值变量,` + + `${summary.n_categorical || 0} 个分类变量。`; + } + + private formatComparisonFindings(data: any, toolCode: string): string { + const stats = data.group_stats || []; + const pFmt = data.p_value_fmt || data.p_value?.toFixed(4); + + if (stats.length >= 2) { + const g1 = stats[0]; + const g2 = stats[1]; + + if (toolCode === 'ST_T_TEST_IND') { + return `${g1.group} 组均值为 ${g1.mean?.toFixed(2)} ± ${g1.sd?.toFixed(2)} (n=${g1.n}),` + + `${g2.group} 组均值为 ${g2.mean?.toFixed(2)} ± ${g2.sd?.toFixed(2)} (n=${g2.n}),` + + `t = ${data.statistic?.toFixed(2)},p ${pFmt}。`; + } else { + return `${g1.group} 组中位数为 ${g1.median?.toFixed(2)} (n=${g1.n}),` + + `${g2.group} 组中位数为 ${g2.median?.toFixed(2)} (n=${g2.n}),` + + `U = ${data.statistic_U?.toFixed(0)},p ${pFmt}。`; + } + } + + return `两组比较:p ${pFmt}。`; + } + + private formatPairedFindings(data: any): string { + const desc = data.descriptive; + const pFmt = data.p_value_fmt || data.p_value?.toFixed(4); + + if (desc) { + return `前测均值 ${desc.before?.mean?.toFixed(2)} ± ${desc.before?.sd?.toFixed(2)},` + + `后测均值 ${desc.after?.mean?.toFixed(2)} ± ${desc.after?.sd?.toFixed(2)},` + + `差值 ${desc.difference?.mean?.toFixed(2)} ± ${desc.difference?.sd?.toFixed(2)},` + + `t = ${data.statistic?.toFixed(2)},p ${pFmt}。`; + } + + return `配对比较:p ${pFmt}。`; + } + + private formatChiSquareFindings(data: any): string { + const pFmt = data.p_value_fmt || data.p_value?.toFixed(4); + const chi = data.statistic?.toFixed(2); + const df = data.df; + + return `χ² = ${chi},df = ${df},p ${pFmt}。`; + } + + private formatCorrelationFindings(data: any): string { + const r = data.statistic?.toFixed(3); + const pFmt = data.p_value_fmt || data.p_value?.toFixed(4); + const method = data.method_code === 'pearson' ? 'Pearson' : 'Spearman'; + + return `${method} 相关系数 r = ${r},p ${pFmt},` + + `相关强度:${data.interpretation || '待解读'}。`; + } + + private formatLogisticFindings(data: any): string { + const coeffs = data.coefficients || []; + const sigCoeffs = coeffs.filter((c: any) => c.significant && c.variable !== '(Intercept)'); + + if (sigCoeffs.length === 0) { + return 'Logistic 回归分析中未发现统计学显著的预测因子。'; + } + + const findings = sigCoeffs.slice(0, 3).map((c: any) => + `${c.variable} (OR=${c.OR}, 95%CI [${c.ci_lower}, ${c.ci_upper}], p ${c.p_value_fmt})` + ); + + return `多因素分析显示以下因素具有统计学显著性:${findings.join(';')}。`; + } + + // ==================== 解读辅助函数 ==================== + + private interpretPValue(p: number): ConclusionSection['significance'] { + if (p < 0.05) return 'significant'; + if (p < 0.10) return 'marginal'; + return 'not_significant'; + } + + private interpretComparison(data: any, sig: ConclusionSection['significance']): string { + if (sig === 'significant') { + return '两组之间存在统计学显著差异。'; + } else if (sig === 'marginal') { + return '两组之间存在边缘显著性差异,建议增加样本量进一步验证。'; + } + return '两组之间无统计学显著差异。'; + } + + private interpretPairedResult(data: any, sig: ConclusionSection['significance']): string { + if (sig === 'significant') { + const diff = data.descriptive?.difference?.mean || 0; + const direction = diff > 0 ? '显著升高' : '显著降低'; + return `配对比较结果表明,后测值较前测值${direction}。`; + } + return '配对比较未发现统计学显著变化。'; + } + + private interpretChiSquare(data: any, sig: ConclusionSection['significance']): string { + if (sig === 'significant') { + const v = data.effect_size?.cramers_v; + const strength = v ? `(效应量 Cramér's V = ${v.toFixed(3)})` : ''; + return `两个分类变量之间存在统计学显著关联${strength}。`; + } + return '两个分类变量之间无统计学显著关联。'; + } + + private interpretCorrelation(data: any, sig: ConclusionSection['significance']): string { + const r = data.statistic || 0; + const direction = r > 0 ? '正相关' : '负相关'; + + if (sig === 'significant') { + return `两变量之间存在统计学显著的${direction}。`; + } + return '两变量之间不存在统计学显著的线性相关。'; + } + + private interpretLogistic(data: any): string { + const coeffs = data.coefficients || []; + const sigCount = coeffs.filter((c: any) => c.significant && c.variable !== '(Intercept)').length; + const totalCount = coeffs.filter((c: any) => c.variable !== '(Intercept)').length; + + return `在纳入的 ${totalCount} 个自变量中,${sigCount} 个对结局变量具有独立的统计学显著效应。`; + } +} + +// 单例导出 +export const conclusionGeneratorService = new ConclusionGeneratorService(); diff --git a/backend/src/modules/ssa/services/DataProfileService.ts b/backend/src/modules/ssa/services/DataProfileService.ts new file mode 100644 index 00000000..59d7f656 --- /dev/null +++ b/backend/src/modules/ssa/services/DataProfileService.ts @@ -0,0 +1,353 @@ +/** + * SSA DataProfile 服务 (Phase 2A) + * + * 调用 Python Tool C 生成数据画像,用于 LLM 生成分析计划 + * + * 时机:用户上传数据时(时机 A) + * 输出:DataProfile JSON,存入 SsaSession.dataProfile + */ + +import axios, { AxiosInstance } from 'axios'; +import { logger } from '../../../common/logging/index.js'; +import { prisma } from '../../../config/database.js'; +import { storage } from '../../../common/storage/index.js'; + +export interface DataProfile { + columns: ColumnProfile[]; + summary: DataSummary; +} + +export interface ColumnProfile { + name: string; + type: 'numeric' | 'categorical' | 'datetime' | 'text'; + missingCount: number; + missingRate: number; + uniqueCount: number; + totalCount: number; + // 数值列 + mean?: number; + std?: number; + median?: number; + min?: number; + max?: number; + q1?: number; + q3?: number; + iqr?: number; + outlierCount?: number; + outlierRate?: number; + skewness?: number; + kurtosis?: number; + // 分类列 + topValues?: Array<{ value: string; count: number; percentage: number }>; + totalLevels?: number; + modeValue?: string; + modeCount?: number; + // 日期列 + minDate?: string; + maxDate?: string; + dateRange?: string; +} + +export interface DataSummary { + totalRows: number; + totalColumns: number; + numericColumns: number; + categoricalColumns: number; + datetimeColumns: number; + textColumns: number; + overallMissingRate: number; + totalMissingCells: number; +} + +export interface QualityScore { + score: number; + grade: 'A' | 'B' | 'C' | 'D'; + gradeDescription: string; + issues: string[]; + recommendations: string[]; +} + +export interface DataProfileResult { + success: boolean; + profile?: DataProfile; + quality?: QualityScore; + executionTime?: number; + error?: string; +} + +export class DataProfileService { + private client: AxiosInstance; + + constructor() { + const baseURL = process.env.EXTRACTION_SERVICE_URL || 'http://localhost:8000'; + + this.client = axios.create({ + baseURL, + timeout: 60000, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + /** + * 为 SSA Session 生成数据画像 + * + * @param sessionId SSA 会话 ID + * @param data 数据数组(JSON 格式) + * @returns DataProfile 结果 + */ + async generateProfile(sessionId: string, data: Record[]): Promise { + const startTime = Date.now(); + + try { + logger.info('[SSA:DataProfile] Generating profile', { + sessionId, + rowCount: data.length, + columnCount: data.length > 0 ? Object.keys(data[0]).length : 0 + }); + + const response = await this.client.post('/api/ssa/data-profile', { + data, + max_unique_values: 20, + include_quality_score: true + }); + + if (!response.data.success) { + throw new Error(response.data.error || 'Profile generation failed'); + } + + const result: DataProfileResult = { + success: true, + profile: response.data.profile, + quality: response.data.quality, + executionTime: response.data.execution_time + }; + + // 保存到数据库 + await this.saveProfileToSession(sessionId, result); + + const executionMs = Date.now() - startTime; + logger.info('[SSA:DataProfile] Profile generated successfully', { + sessionId, + executionMs, + summary: result.profile?.summary + }); + + return result; + + } catch (error: any) { + const executionMs = Date.now() - startTime; + logger.error('[SSA:DataProfile] Profile generation failed', { + sessionId, + error: error.message, + executionMs + }); + + return { + success: false, + error: error.message, + executionTime: executionMs / 1000 + }; + } + } + + /** + * 从 CSV 内容直接生成画像(让 Python pandas 解析 CSV) + * + * @param sessionId SSA 会话 ID + * @param csvContent CSV 文件内容 + * @returns DataProfile 结果 + */ + async generateProfileFromCSV(sessionId: string, csvContent: string): Promise { + const startTime = Date.now(); + + try { + logger.info('[SSA:DataProfile] Generating profile from CSV', { + sessionId, + contentLength: csvContent.length + }); + + // 直接发送 CSV 内容给 Python 服务,让 pandas 解析 + const response = await this.client.post('/api/ssa/data-profile-csv', { + csv_content: csvContent, + max_unique_values: 20, + include_quality_score: true + }); + + if (!response.data.success) { + throw new Error(response.data.error || 'Profile generation failed'); + } + + const result: DataProfileResult = { + success: true, + profile: response.data.profile, + quality: response.data.quality, + executionTime: response.data.execution_time + }; + + // 保存到数据库 + await this.saveProfileToSession(sessionId, result); + + const executionMs = Date.now() - startTime; + logger.info('[SSA:DataProfile] Profile generated from CSV successfully', { + sessionId, + executionMs, + summary: result.profile?.summary + }); + + return result; + + } catch (error: any) { + const executionMs = Date.now() - startTime; + logger.error('[SSA:DataProfile] CSV profile generation failed', { + sessionId, + error: error.message, + executionMs + }); + + return { + success: false, + error: error.message, + executionTime: executionMs / 1000 + }; + } + } + + /** + * 从 OSS 加载数据并生成画像 + * + * @param sessionId SSA 会话 ID + * @returns DataProfile 结果 + */ + async generateProfileFromSession(sessionId: string): Promise { + try { + const session = await prisma.ssaSession.findUnique({ + where: { id: sessionId } + }); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + // 如果已有画像,直接返回 + if (session.dataProfile) { + logger.info('[SSA:DataProfile] Using cached profile', { sessionId }); + return { + success: true, + profile: session.dataProfile as unknown as DataProfile + }; + } + + // 从 dataPayload 或 OSS 加载数据 + if (session.dataPayload) { + // JSON 格式数据,直接调用原方法 + const data = session.dataPayload as unknown as Record[]; + return await this.generateProfile(sessionId, data); + } else if (session.dataOssKey) { + // 从 OSS 下载文件 + const buffer = await storage.download(session.dataOssKey); + const content = buffer.toString('utf-8'); + + // 检测文件格式:JSON 或 CSV + const trimmedContent = content.trim(); + if (trimmedContent.startsWith('[') || trimmedContent.startsWith('{')) { + // JSON 格式 + const data = JSON.parse(content); + return await this.generateProfile(sessionId, data); + } else { + // CSV 格式,直接发给 Python 解析(更高效、更可靠) + return await this.generateProfileFromCSV(sessionId, content); + } + } else { + throw new Error('No data available for session'); + } + + } catch (error: any) { + logger.error('[SSA:DataProfile] Failed to generate profile from session', { + sessionId, + error: error.message + }); + + return { + success: false, + error: error.message + }; + } + } + + /** + * 保存画像到 Session + */ + private async saveProfileToSession(sessionId: string, result: DataProfileResult): Promise { + try { + await prisma.ssaSession.update({ + where: { id: sessionId }, + data: { + dataProfile: result.profile as any + } + }); + + logger.info('[SSA:DataProfile] Profile saved to session', { sessionId }); + } catch (error: any) { + logger.error('[SSA:DataProfile] Failed to save profile', { + sessionId, + error: error.message + }); + } + } + + /** + * 获取已缓存的画像 + */ + async getCachedProfile(sessionId: string): Promise { + const session = await prisma.ssaSession.findUnique({ + where: { id: sessionId }, + select: { dataProfile: true } + }); + + return session?.dataProfile as unknown as DataProfile | null; + } + + /** + * 为 LLM 生成精简版画像摘要 + * 用于 Prompt 注入,控制 Token 消耗 + */ + generateProfileSummaryForLLM(profile: DataProfile): string { + const { summary, columns } = profile; + + const lines: string[] = [ + `## 数据概况`, + `- 样本量: ${summary.totalRows} 行`, + `- 变量数: ${summary.totalColumns} 列 (${summary.numericColumns} 数值, ${summary.categoricalColumns} 分类)`, + `- 整体缺失率: ${summary.overallMissingRate}%`, + '', + `## 变量清单` + ]; + + for (const col of columns) { + let desc = `- **${col.name}** [${col.type}]`; + + if (col.missingRate > 0) { + desc += ` (缺失 ${col.missingRate}%)`; + } + + if (col.type === 'numeric') { + desc += `: 均值=${col.mean}, SD=${col.std}, 范围=[${col.min}, ${col.max}]`; + if (col.outlierCount && col.outlierCount > 0) { + desc += `, ${col.outlierCount}个异常值`; + } + } else if (col.type === 'categorical') { + const levels = col.topValues?.slice(0, 5).map(v => v.value).join(', '); + desc += `: ${col.totalLevels}个水平 (${levels}${col.totalLevels && col.totalLevels > 5 ? '...' : ''})`; + } + + lines.push(desc); + } + + return lines.join('\n'); + } +} + +// 单例导出 +export const dataProfileService = new DataProfileService(); diff --git a/backend/src/modules/ssa/services/WorkflowExecutorService.ts b/backend/src/modules/ssa/services/WorkflowExecutorService.ts new file mode 100644 index 00000000..13dfe09f --- /dev/null +++ b/backend/src/modules/ssa/services/WorkflowExecutorService.ts @@ -0,0 +1,521 @@ +/** + * SSA Workflow Executor Service (Phase 2A) + * + * 流程执行器:串联执行多个统计工具 + * + * 功能: + * - 按顺序执行工作流步骤 + * - JIT 护栏检查(执行前) + * - 结果在步骤间传递 + * - 容错管道:支持部分成功 + * - SSE 实时进度推送 + */ + +import { EventEmitter } from 'events'; +import axios, { AxiosInstance } from 'axios'; +import { logger } from '../../../common/logging/index.js'; +import { prisma } from '../../../config/database.js'; +import { storage } from '../../../common/storage/index.js'; +import { WorkflowStep, ToolCode, AVAILABLE_TOOLS } from './WorkflowPlannerService.js'; +import { conclusionGeneratorService, ConclusionReport } from './ConclusionGeneratorService.js'; + +// 步骤执行结果 +export interface StepResult { + stepOrder: number; + toolCode: string; + toolName: string; + status: 'success' | 'warning' | 'error' | 'skipped'; + result?: any; + guardrailChecks?: GuardrailCheck[]; + error?: { + code: string; + message: string; + userHint: string; + }; + executionMs: number; +} + +// 护栏检查结果 +export interface GuardrailCheck { + checkName: string; + passed: boolean; + pValue?: number; + recommendation: string; +} + +// SSE 消息格式 +export interface SSEMessage { + type: 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete'; + step: number; + total_steps?: number; + toolCode: string; + toolName: string; + status: 'running' | 'success' | 'error' | 'skipped' | 'warning'; + message: string; + progress?: number; + durationMs?: number; + result?: any; + error?: { + code: string; + message: string; + userHint: string; + }; + timestamp: string; +} + +// 工作流执行结果 +export interface WorkflowExecutionResult { + workflowId: string; + status: 'completed' | 'partial' | 'error'; + totalSteps: number; + completedSteps: number; + successSteps: number; + results: StepResult[]; + conclusion?: ConclusionReport; + executionMs: number; +} + +export class WorkflowExecutorService extends EventEmitter { + private rClient: AxiosInstance; + + constructor() { + super(); + + const rServiceUrl = process.env.R_SERVICE_URL || 'http://localhost:8082'; + + this.rClient = axios.create({ + baseURL: rServiceUrl, + timeout: 120000, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + /** + * 执行工作流 + * + * @param workflowId 工作流 ID + * @param sessionId 会话 ID + * @returns 执行结果 + */ + async executeWorkflow(workflowId: string, sessionId: string): Promise { + const startTime = Date.now(); + const results: StepResult[] = []; + + logger.info('[SSA:Executor] Starting workflow execution', { workflowId, sessionId }); + + try { + // 获取工作流和步骤 + const workflow = await prisma.ssaWorkflow.findUnique({ + where: { id: workflowId }, + include: { steps: { orderBy: { stepOrder: 'asc' } } } + }); + + if (!workflow) { + throw new Error(`Workflow not found: ${workflowId}`); + } + + // 获取会话数据 + const session = await prisma.ssaSession.findUnique({ + where: { id: sessionId } + }); + + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + // 更新工作流状态 + await prisma.ssaWorkflow.update({ + where: { id: workflowId }, + data: { + status: 'running', + startedAt: new Date() + } + }); + + // 准备数据源 + const dataSource = await this.prepareDataSource(session); + + // 逐步执行 + let successCount = 0; + let previousResults: any = null; + + for (const step of workflow.steps) { + const stepResult = await this.executeStep( + step, + session, + dataSource, + previousResults + ); + + results.push(stepResult); + + // 更新步骤状态 + await prisma.ssaWorkflowStep.update({ + where: { id: step.id }, + data: { + status: stepResult.status, + outputResult: stepResult.result, + guardrailChecks: stepResult.guardrailChecks as any, + errorInfo: stepResult.error as any, + executionMs: stepResult.executionMs, + completedAt: new Date() + } + }); + + // 更新工作流进度 + await prisma.ssaWorkflow.update({ + where: { id: workflowId }, + data: { completedSteps: { increment: 1 } } + }); + + if (stepResult.status === 'success' || stepResult.status === 'warning') { + successCount++; + previousResults = stepResult.result; + } + + // 发送 SSE 消息 + this.emitProgress({ + type: stepResult.status === 'error' ? 'step_error' : 'step_complete', + step: step.stepOrder, + total_steps: workflow.steps.length, + toolCode: step.toolCode, + toolName: step.toolName, + status: stepResult.status, + message: stepResult.status === 'error' + ? `${step.toolName} 执行失败: ${stepResult.error?.message}` + : `${step.toolName} 执行完成`, + result: stepResult.result, + durationMs: stepResult.executionMs, + error: stepResult.error, + timestamp: new Date().toISOString() + }); + + // 如果是关键错误,决定是否继续 + if (stepResult.status === 'error' && this.isCriticalStep(step.stepOrder, workflow.steps.length)) { + logger.warn('[SSA:Executor] Critical step failed, stopping workflow', { + workflowId, + step: step.stepOrder + }); + break; + } + } + + // 确定最终状态 + const executionMs = Date.now() - startTime; + let finalStatus: 'completed' | 'partial' | 'error' = 'completed'; + + if (successCount === 0) { + finalStatus = 'error'; + } else if (successCount < workflow.steps.length) { + finalStatus = 'partial'; + } + + // 更新工作流最终状态 + await prisma.ssaWorkflow.update({ + where: { id: workflowId }, + data: { + status: finalStatus, + completedAt: new Date() + } + }); + + // 发送完成消息 + this.emitProgress({ + type: 'workflow_complete', + step: workflow.steps.length, + toolCode: '', + toolName: '', + status: finalStatus === 'completed' ? 'success' : finalStatus === 'partial' ? 'warning' : 'error', + message: finalStatus === 'completed' + ? `分析流程执行完成,共 ${successCount} 个步骤` + : `分析流程部分完成,${successCount}/${workflow.steps.length} 个步骤成功`, + timestamp: new Date().toISOString() + }); + + // 生成综合结论 + let conclusion: ConclusionReport | undefined; + if (successCount > 0) { + const workflowPlan = workflow.workflowPlan as any; + conclusion = conclusionGeneratorService.generateConclusion( + results, + workflowPlan?.goal || '统计分析' + ); + } + + logger.info('[SSA:Executor] Workflow execution finished', { + workflowId, + status: finalStatus, + successCount, + totalSteps: workflow.steps.length, + executionMs, + hasConclusion: !!conclusion + }); + + return { + workflowId, + status: finalStatus, + totalSteps: workflow.steps.length, + completedSteps: results.length, + successSteps: successCount, + results, + conclusion, + executionMs + }; + + } catch (error: any) { + logger.error('[SSA:Executor] Workflow execution failed', { + workflowId, + error: error.message + }); + + await prisma.ssaWorkflow.update({ + where: { id: workflowId }, + data: { status: 'error' } + }); + + return { + workflowId, + status: 'error', + totalSteps: 0, + completedSteps: 0, + successSteps: 0, + results, + executionMs: Date.now() - startTime + }; + } + } + + /** + * 执行单个步骤 + */ + private async executeStep( + step: any, + session: any, + dataSource: any, + previousResults: any + ): Promise { + const startTime = Date.now(); + + // 发送开始消息 + this.emitProgress({ + type: 'step_start', + step: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: 'running', + message: `正在执行 ${step.toolName}...`, + timestamp: new Date().toISOString() + }); + + // 更新步骤状态 + await prisma.ssaWorkflowStep.update({ + where: { id: step.id }, + data: { + status: 'running', + startedAt: new Date() + } + }); + + try { + // JIT 护栏检查 + let guardrailChecks: GuardrailCheck[] | undefined; + + if (this.needsGuardrailCheck(step.toolCode)) { + this.emitProgress({ + type: 'step_progress', + step: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: 'running', + message: '正在执行统计假设检验(JIT护栏)...', + progress: 30, + timestamp: new Date().toISOString() + }); + + guardrailChecks = await this.runJITGuardrails(dataSource, step.toolCode, step.inputParams); + } + + // 发送进度 + this.emitProgress({ + type: 'step_progress', + step: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: 'running', + message: `正在执行 ${step.toolName}...`, + progress: 60, + timestamp: new Date().toISOString() + }); + + // 调用 R 服务 + const response = await this.rClient.post(`/api/v1/skills/${step.toolCode}`, { + data_source: dataSource, + params: step.inputParams, + original_filename: session.title || 'data.csv', + guardrails: { + check_normality: true, + auto_fix: true + } + }); + + const executionMs = Date.now() - startTime; + + if (response.data.status === 'error' || response.data.status === 'blocked') { + return { + stepOrder: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: 'error', + guardrailChecks, + error: { + code: response.data.error_code || 'E100', + message: response.data.message || '执行失败', + userHint: response.data.user_hint || '请检查数据和参数' + }, + executionMs + }; + } + + return { + stepOrder: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: response.data.warnings?.length > 0 ? 'warning' : 'success', + result: { + ...response.data.results, + plots: response.data.plots, + result_table: response.data.result_table, + reproducible_code: response.data.reproducible_code, + trace_log: response.data.trace_log, + warnings: response.data.warnings, + }, + guardrailChecks, + executionMs + }; + + } catch (error: any) { + const executionMs = Date.now() - startTime; + + logger.error('[SSA:Executor] Step execution failed', { + step: step.stepOrder, + toolCode: step.toolCode, + error: error.message + }); + + return { + stepOrder: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: 'error', + error: { + code: 'E100', + message: error.message, + userHint: '执行过程中发生错误,请重试' + }, + executionMs + }; + } + } + + /** + * JIT 护栏检查 + */ + private async runJITGuardrails( + dataSource: any, + toolCode: string, + params: any + ): Promise { + try { + const response = await this.rClient.post('/api/v1/guardrails/jit', { + data_source: dataSource, + tool_code: toolCode, + params + }); + + if (response.data.status === 'success') { + return response.data.checks || []; + } + } catch (error: any) { + logger.warn('[SSA:Executor] JIT guardrail check failed', { + toolCode, + error: error.message + }); + } + + return []; + } + + /** + * 判断是否需要护栏检查 + */ + private needsGuardrailCheck(toolCode: string): boolean { + const toolsNeedingGuardrails = [ + 'ST_T_TEST_IND', + 'ST_T_TEST_PAIRED', + 'ST_CORRELATION' + ]; + return toolsNeedingGuardrails.includes(toolCode); + } + + /** + * 判断是否是关键步骤 + */ + private isCriticalStep(stepOrder: number, totalSteps: number): boolean { + // 第一步(描述统计)失败才算关键错误 + return stepOrder === 1; + } + + /** + * 准备数据源 + */ + private async prepareDataSource(session: any): Promise { + if (session.dataPayload) { + return { + type: 'inline', + data: session.dataPayload + }; + } else if (session.dataOssKey) { + const signedUrl = await storage.getUrl(session.dataOssKey, 3600); + return { + type: 'oss', + oss_url: signedUrl + }; + } + + throw new Error('No data source available'); + } + + /** + * 发送进度消息 + */ + private emitProgress(message: SSEMessage): void { + this.emit('progress', message); + + logger.debug('[SSA:Executor] Progress emitted', { + type: message.type, + step: message.step, + status: message.status + }); + } + + /** + * 获取工作流执行状态 + */ + async getWorkflowStatus(workflowId: string): Promise { + const workflow = await prisma.ssaWorkflow.findUnique({ + where: { id: workflowId }, + include: { + steps: { + orderBy: { stepOrder: 'asc' } + } + } + }); + + return workflow; + } +} + +// 单例导出 +export const workflowExecutorService = new WorkflowExecutorService(); diff --git a/backend/src/modules/ssa/services/WorkflowPlannerService.ts b/backend/src/modules/ssa/services/WorkflowPlannerService.ts new file mode 100644 index 00000000..2f17237f --- /dev/null +++ b/backend/src/modules/ssa/services/WorkflowPlannerService.ts @@ -0,0 +1,603 @@ +/** + * SSA Workflow Planner Service (Phase 2A) + * + * 路径规划器:LLM 驱动的多工具流程规划 + * + * 功能: + * - 理解用户意图 + 数据特征 + * - 规划 2-7 步分析流程 + * - 选择合适的统计工具组合 + */ + +import { logger } from '../../../common/logging/index.js'; +import { prisma } from '../../../config/database.js'; +import { DataProfile, dataProfileService } from './DataProfileService.js'; + +// 可用工具定义 +export const AVAILABLE_TOOLS = { + ST_DESCRIPTIVE: { + code: 'ST_DESCRIPTIVE', + name: '描述性统计', + category: 'basic', + description: '数据概况、基线特征表', + inputParams: ['variables', 'group_var?'], + outputType: 'summary' + }, + ST_T_TEST_IND: { + code: 'ST_T_TEST_IND', + name: '独立样本T检验', + category: 'parametric', + description: '两组连续变量比较(参数方法)', + inputParams: ['group_var', 'value_var'], + outputType: 'comparison', + prerequisite: '正态分布', + fallback: 'ST_MANN_WHITNEY' + }, + ST_MANN_WHITNEY: { + code: 'ST_MANN_WHITNEY', + name: 'Mann-Whitney U检验', + category: 'nonparametric', + description: '两组连续/等级变量比较(非参数方法)', + inputParams: ['group_var', 'value_var'], + outputType: 'comparison' + }, + ST_T_TEST_PAIRED: { + code: 'ST_T_TEST_PAIRED', + name: '配对T检验', + category: 'parametric', + description: '配对设计的前后对比', + inputParams: ['before_var', 'after_var'], + outputType: 'comparison' + }, + ST_CHI_SQUARE: { + code: 'ST_CHI_SQUARE', + name: '卡方检验', + category: 'categorical', + description: '两个分类变量的独立性检验', + inputParams: ['var1', 'var2'], + outputType: 'association' + }, + ST_CORRELATION: { + code: 'ST_CORRELATION', + name: '相关分析', + category: 'correlation', + description: 'Pearson/Spearman相关系数', + inputParams: ['var_x', 'var_y', 'method?'], + outputType: 'correlation' + }, + ST_LOGISTIC_BINARY: { + code: 'ST_LOGISTIC_BINARY', + name: '二元Logistic回归', + category: 'regression', + description: '二分类结局的多因素分析', + inputParams: ['outcome_var', 'predictors', 'confounders?'], + outputType: 'regression' + } +} as const; + +export type ToolCode = keyof typeof AVAILABLE_TOOLS; + +// 工作流步骤 +export interface WorkflowStep { + stepOrder: number; + toolCode: ToolCode; + toolName: string; + inputParams: Record; + purpose: string; + dependsOn?: number[]; +} + +// 工作流计划(内部使用) +export interface WorkflowPlanInternal { + goal: string; + reasoning: string; + steps: WorkflowStep[]; + estimatedDuration: string; +} + +// 工作流计划(API 返回格式,与前端类型匹配) +export interface WorkflowPlan { + workflow_id: string; + session_id: string; + title: string; + description: string; + total_steps: number; + steps: Array<{ + step_number: number; + tool_code: string; + tool_name: string; + description: string; + params: Record; + depends_on?: number[]; + }>; + estimated_time_seconds?: number; + created_at: string; +} + +// 用户意图解析结果 +export interface ParsedIntent { + goal: string; + analysisType: 'comparison' | 'correlation' | 'regression' | 'descriptive' | 'mixed'; + variables: { + mentioned?: string[]; // 用户在查询中提到的变量 + outcome?: string; // 结局变量 + predictors?: string[]; // 预测变量/自变量 + grouping?: string; // 分组变量 + continuous?: string[]; // 所有连续变量 + categorical?: string[]; // 所有分类变量 + }; + design?: 'independent' | 'paired' | 'longitudinal'; +} + +export class WorkflowPlannerService { + + /** + * 生成多步骤工作流计划 + * + * @param sessionId 会话 ID + * @param userQuery 用户的分析请求 + * @param profile 数据画像(可选,如果不传会自动获取) + */ + async planWorkflow( + sessionId: string, + userQuery: string, + profile?: DataProfile + ): Promise { + + logger.info('[SSA:Planner] Planning workflow', { sessionId, userQuery }); + + // 获取数据画像 + if (!profile) { + profile = await dataProfileService.getCachedProfile(sessionId) || undefined; + } + + // 解析用户意图 + const intent = this.parseUserIntent(userQuery, profile); + + // 根据意图生成工作流 + const steps = this.generateSteps(intent, profile); + + // 构建内部计划 + const internalPlan: WorkflowPlanInternal = { + goal: intent.goal, + reasoning: this.generateReasoning(intent, steps), + steps, + estimatedDuration: this.estimateDuration(steps) + }; + + // 保存到数据库 + const workflowId = await this.saveWorkflow(sessionId, internalPlan); + + logger.info('[SSA:Planner] Workflow planned', { + sessionId, + stepCount: steps.length, + tools: steps.map(s => s.toolCode) + }); + + // 转换为前端期望的格式 + const plan: WorkflowPlan = { + workflow_id: workflowId, + session_id: sessionId, + title: intent.goal, + description: internalPlan.reasoning, + total_steps: steps.length, + steps: steps.map(s => ({ + step_number: s.stepOrder, + tool_code: s.toolCode, + tool_name: s.toolName, + description: s.purpose, + params: s.inputParams, + depends_on: s.dependsOn + })), + estimated_time_seconds: steps.length * 5, + created_at: new Date().toISOString() + }; + + return plan; + } + + /** + * 解析用户意图(改进版:识别用户提到的变量并选择合适方法) + */ + private parseUserIntent(userQuery: string, profile?: DataProfile): ParsedIntent { + const query = userQuery.toLowerCase(); + + // 基于关键词的意图识别 + let analysisType: ParsedIntent['analysisType'] = 'descriptive'; + let design: ParsedIntent['design'] = 'independent'; + + if (query.includes('比较') || query.includes('差异') || query.includes('不同')) { + analysisType = 'comparison'; + } else if (query.includes('相关') || query.includes('关系') || query.includes('关联')) { + analysisType = 'correlation'; + } else if (query.includes('影响') || query.includes('因素') || query.includes('预测') || query.includes('回归')) { + analysisType = 'regression'; + } + + if (query.includes('前后') || query.includes('配对') || query.includes('变化')) { + design = 'paired'; + } + + // 从用户查询中提取变量名 + const variables: ParsedIntent['variables'] = { + mentioned: [], // 用户提到的变量 + outcome: undefined, // 结局变量 + predictors: [], // 预测变量/自变量 + continuous: [], + categorical: [] + }; + + if (profile) { + const allColumns = profile.columns.map(c => c.name); + const numericCols = profile.columns.filter(c => c.type === 'numeric').map(c => c.name); + const categoricalCols = profile.columns.filter(c => c.type === 'categorical').map(c => c.name); + + variables.continuous = numericCols; + variables.categorical = categoricalCols; + + // 从查询中识别用户提到的变量名(不区分大小写) + for (const col of allColumns) { + if (query.includes(col.toLowerCase())) { + variables.mentioned!.push(col); + } + } + + // 尝试识别结局变量和预测变量 + // 规则:A对B的影响 / A与B的相关性 → B 是结局,A 是预测因素 + const influenceMatch = userQuery.match(/(.+?)(?:对|影响|预测)(.+?)(?:的|$)/); + const correlationMatch = userQuery.match(/(.+?)(?:与|和|跟)(.+?)(?:的相关|的关系|的关联)/); + + if (influenceMatch) { + const predictorPart = influenceMatch[1]; + const outcomePart = influenceMatch[2]; + + // 找出结局变量 + for (const col of allColumns) { + if (outcomePart.toLowerCase().includes(col.toLowerCase())) { + variables.outcome = col; + break; + } + } + // 找出预测变量 + for (const col of allColumns) { + if (predictorPart.toLowerCase().includes(col.toLowerCase())) { + variables.predictors!.push(col); + } + } + } else if (correlationMatch) { + const var1Part = correlationMatch[1]; + const var2Part = correlationMatch[2]; + + for (const col of allColumns) { + if (var1Part.toLowerCase().includes(col.toLowerCase()) || + var2Part.toLowerCase().includes(col.toLowerCase())) { + variables.mentioned!.push(col); + } + } + } + + // 如果有明确提到的变量但没有解析出结局/预测,使用提到的变量 + if (variables.mentioned!.length >= 2 && !variables.outcome) { + // 最后一个通常是结局变量 + variables.outcome = variables.mentioned![variables.mentioned!.length - 1]; + variables.predictors = variables.mentioned!.slice(0, -1); + } + + // 尝试识别分组变量(二分类) + const binaryCol = profile.columns.find(c => c.type === 'categorical' && c.totalLevels === 2); + if (binaryCol) { + variables.grouping = binaryCol.name; + } + + logger.info('[WorkflowPlanner] Parsed variables from query', { + mentioned: variables.mentioned, + outcome: variables.outcome, + predictors: variables.predictors + }); + } + + return { + goal: userQuery, + analysisType, + design, + variables + }; + } + + /** + * 判断变量是否为分类型 + */ + private isVariableCategorical(varName: string, profile?: DataProfile): boolean { + if (!profile) return false; + const col = profile.columns.find(c => c.name.toLowerCase() === varName.toLowerCase()); + return col?.type === 'categorical'; + } + + /** + * 判断变量是否为二分类 + */ + private isVariableBinary(varName: string, profile?: DataProfile): boolean { + if (!profile) return false; + const col = profile.columns.find(c => c.name.toLowerCase() === varName.toLowerCase()); + return col?.type === 'categorical' && col.totalLevels === 2; + } + + /** + * 根据意图生成工作流步骤(改进版:根据变量类型智能选择方法) + */ + private generateSteps(intent: ParsedIntent, profile?: DataProfile): WorkflowStep[] { + const steps: WorkflowStep[] = []; + let stepOrder = 1; + + // 获取用户提到的变量 + const mentionedVars = intent.variables?.mentioned || []; + const outcomeVar = intent.variables?.outcome; + const predictorVars = intent.variables?.predictors || []; + + // 第一步:总是先做描述性统计 + const descVars = mentionedVars.length > 0 + ? mentionedVars + : (intent.variables?.continuous || []).slice(0, 5); + + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_DESCRIPTIVE', + toolName: AVAILABLE_TOOLS.ST_DESCRIPTIVE.name, + inputParams: { + variables: descVars, + group_var: intent.variables?.grouping + }, + purpose: '了解数据的基本特征和分布' + }); + + // 根据分析类型和变量类型添加核心分析步骤 + switch (intent.analysisType) { + case 'comparison': + if (intent.design === 'paired') { + // 配对设计 + if (intent.variables?.continuous && intent.variables.continuous.length >= 2) { + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_T_TEST_PAIRED', + toolName: AVAILABLE_TOOLS.ST_T_TEST_PAIRED.name, + inputParams: { + before_var: intent.variables.continuous[0], + after_var: intent.variables.continuous[1] + }, + purpose: '检验配对样本的均值差异', + dependsOn: [1] + }); + } + } else { + // 独立样本设计 + if (intent.variables?.grouping && intent.variables?.continuous?.length) { + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_T_TEST_IND', + toolName: AVAILABLE_TOOLS.ST_T_TEST_IND.name, + inputParams: { + group_var: intent.variables.grouping, + value_var: intent.variables.continuous[0] + }, + purpose: '检验两组均值是否存在显著差异(正态时用T检验,否则自动降级为Mann-Whitney)', + dependsOn: [1] + }); + } + } + break; + + case 'correlation': + // 根据变量类型选择相关性分析方法 + if (mentionedVars.length >= 2) { + const var1 = mentionedVars[0]; + const var2 = mentionedVars[1]; + const var1IsCat = this.isVariableCategorical(var1, profile); + const var2IsCat = this.isVariableCategorical(var2, profile); + + if (var1IsCat && var2IsCat) { + // 两个分类变量 → 卡方检验 + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_CHI_SQUARE', + toolName: AVAILABLE_TOOLS.ST_CHI_SQUARE.name, + inputParams: { + var1: var1, + var2: var2 + }, + purpose: `分析 ${var1} 与 ${var2} 两个分类变量的关联性`, + dependsOn: [1] + }); + } else if (!var1IsCat && !var2IsCat) { + // 两个连续变量 → Pearson/Spearman 相关 + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_CORRELATION', + toolName: AVAILABLE_TOOLS.ST_CORRELATION.name, + inputParams: { + var_x: var1, + var_y: var2, + method: 'auto' + }, + purpose: `分析 ${var1} 与 ${var2} 的相关性`, + dependsOn: [1] + }); + } else { + // 一个分类一个连续 → T检验或点双列相关 + const catVar = var1IsCat ? var1 : var2; + const contVar = var1IsCat ? var2 : var1; + + if (this.isVariableBinary(catVar, profile)) { + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_T_TEST_IND', + toolName: AVAILABLE_TOOLS.ST_T_TEST_IND.name, + inputParams: { + group_var: catVar, + value_var: contVar + }, + purpose: `比较 ${catVar} 不同组别下 ${contVar} 的差异(点双列相关的等价检验)`, + dependsOn: [1] + }); + } + } + } else if (intent.variables?.continuous && intent.variables.continuous.length >= 2) { + // 没有明确提到变量,使用默认的连续变量 + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_CORRELATION', + toolName: AVAILABLE_TOOLS.ST_CORRELATION.name, + inputParams: { + var_x: intent.variables.continuous[0], + var_y: intent.variables.continuous[1], + method: 'auto' + }, + purpose: '分析两个连续变量的相关性', + dependsOn: [1] + }); + } + break; + + case 'regression': + // 多因素分析 - 使用用户指定的结局变量和预测因素 + const regressionOutcome = outcomeVar || intent.variables?.grouping; + const regressionPredictors = predictorVars.length > 0 + ? predictorVars + : intent.variables?.continuous?.slice(0, 5) || []; + + if (regressionOutcome && regressionPredictors.length > 0) { + // 判断结局变量类型 + const outcomeBinary = this.isVariableBinary(regressionOutcome, profile); + const outcomeCat = this.isVariableCategorical(regressionOutcome, profile); + + logger.info('[WorkflowPlanner] Regression analysis', { + outcome: regressionOutcome, + predictors: regressionPredictors, + outcomeBinary, + outcomeCat + }); + + if (outcomeBinary || outcomeCat) { + // 二分类/分类结局 → Logistic 回归 + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_LOGISTIC_BINARY', + toolName: AVAILABLE_TOOLS.ST_LOGISTIC_BINARY.name, + inputParams: { + outcome_var: regressionOutcome, + predictors: regressionPredictors + }, + purpose: `分析 ${regressionPredictors.join('、')} 对 ${regressionOutcome} 的影响(二元 Logistic 回归)`, + dependsOn: [1] + }); + } else { + // 连续结局 → 暂时也使用 Logistic 回归(TODO: 添加线性回归工具) + // 实际应该使用线性回归,但当前工具库暂未支持 + logger.warn('[WorkflowPlanner] Linear regression not yet implemented, falling back to descriptive stats'); + // 添加一个额外的描述性统计步骤作为替代 + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_CORRELATION', + toolName: AVAILABLE_TOOLS.ST_CORRELATION.name, + inputParams: { + var_x: regressionPredictors[0], + var_y: regressionOutcome, + method: 'auto' + }, + purpose: `分析 ${regressionPredictors[0]} 与 ${regressionOutcome} 的相关性(线性回归待开发)`, + dependsOn: [1] + }); + } + } else if (intent.variables?.grouping && intent.variables?.continuous?.length) { + // 降级:使用默认的分组变量作为结局 + steps.push({ + stepOrder: stepOrder++, + toolCode: 'ST_LOGISTIC_BINARY', + toolName: AVAILABLE_TOOLS.ST_LOGISTIC_BINARY.name, + inputParams: { + outcome_var: intent.variables.grouping, + predictors: intent.variables.continuous?.slice(0, 5) || [] + }, + purpose: '多因素分析:控制混杂后分析各因素的独立效应', + dependsOn: [1] + }); + } + break; + } + + return steps; + } + + /** + * 生成规划理由说明 + */ + private generateReasoning(intent: ParsedIntent, steps: WorkflowStep[]): string { + const reasons: string[] = []; + + reasons.push(`根据您的分析目标「${intent.goal}」,我为您规划了 ${steps.length} 步分析流程:`); + + for (const step of steps) { + reasons.push(`${step.stepOrder}. ${step.toolName}:${step.purpose}`); + } + + if (intent.analysisType === 'comparison') { + reasons.push('\n说明:系统会自动进行正态性检验,如不满足正态性假设,将自动切换为非参数方法。'); + } + + return reasons.join('\n'); + } + + /** + * 估算执行时长 + */ + private estimateDuration(steps: WorkflowStep[]): string { + const secondsPerStep = 5; + const totalSeconds = steps.length * secondsPerStep; + + if (totalSeconds < 60) { + return `约 ${totalSeconds} 秒`; + } else { + return `约 ${Math.ceil(totalSeconds / 60)} 分钟`; + } + } + + /** + * 保存工作流到数据库 + */ + private async saveWorkflow(sessionId: string, plan: WorkflowPlanInternal): Promise { + const workflow = await prisma.ssaWorkflow.create({ + data: { + sessionId, + status: 'pending', + totalSteps: plan.steps.length, + completedSteps: 0, + workflowPlan: plan as any, + reasoning: plan.reasoning + } + }); + + // 创建步骤记录 + for (const step of plan.steps) { + await prisma.ssaWorkflowStep.create({ + data: { + workflowId: workflow.id, + stepOrder: step.stepOrder, + toolCode: step.toolCode, + toolName: step.toolName, + status: 'pending', + inputParams: step.inputParams + } + }); + } + + logger.info('[SSA:Planner] Workflow saved', { + sessionId, + workflowId: workflow.id, + stepCount: plan.steps.length + }); + + return workflow.id; + } +} + +// 单例导出 +export const workflowPlannerService = new WorkflowPlannerService(); diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index eb5872f0..f4bf22ef 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,11 +1,12 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v5.5 +> **文档版本:** v5.6 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 -> **最后更新:** 2026-02-19 +> **最后更新:** 2026-02-20 > **🎉 重大里程碑:** -> - **🆕 2026-02-19:SSA T 检验端到端测试通过!** 完整流程验证 + 9 个 Bug 修复 + Phase 1 核心完成 85% +> - **🆕 2026-02-20:SSA Phase 2A 前端集成完成!** 多步骤工作流端到端 + V11 UI联调 + Block-based 架构共识 +> - **2026-02-19:SSA T 检验端到端测试通过!** 完整流程验证 + 9 个 Bug 修复 + Phase 1 核心完成 85% > - **2026-02-19:SSA Week 1 开发完成!** R Docker 镜像构建成功 + 后端/前端骨架 + 规范对齐 > - **2026-02-18:SSA MVP 开发计划 v1.5 完成!** Brain-Hand架构 + 统计护栏 + HITL + 专家配置体系 > - **2026-02-18:RVW V2.0 Week 3 完成!** 统计验证扩展 + 负号归一化 + 文件格式提示 + 用户体验优化 @@ -22,14 +23,14 @@ > - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程 > - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层 > -> **🆕 最新进展(SSA T 检验端到端测试通过 2026-02-19):** -> - ✅ **🎉 完整流程验证**:数据上传 → 计划生成 → 分析执行 → 结果展示 → 代码下载 -> - ✅ **R 服务 Bug 修复**:缺失值自动过滤,解决分组变量 3 组问题 -> - ✅ **类型推断优化**:0/1 数字列正确识别为分类变量 -> - ✅ **错误处理增强**:R 服务错误信息正确传递给前端显示 -> - ✅ **文件名动态生成**:`{toolName}_{dataName}_{MMDD}_{HHmm}.R` -> - ✅ **前端模块激活**:智能统计分析入口可用 -> - ✅ **用户会话隔离**:不同用户数据正确隔离 +> **🆕 最新进展(SSA Phase 2A 前端集成完成 2026-02-20):** +> - ✅ **🎉 多步骤工作流端到端**:数据上传 → 质量报告 → 多步骤规划 → SSE 实时执行 → 结果展示 → 报告/代码导出 +> - ✅ **V11 UI 前后端联调**:Gemini 风格对话界面 + 多任务支持 + 单页滚动布局 +> - ✅ **Python 数据质量服务集成**:CSV 直传 Python 解析,修复端口/环境变量问题 +> - ✅ **意图识别增强**:正则提取变量名 + 变量类型判断 → 智能选择统计方法 +> - ✅ **描述性统计完整支持**:专用 DescriptiveResultView 组件 + Word 导出 +> - ✅ **6 个前端 Bug 修复**:SAP 误显示、布局混乱、SSE 卡死、结果丢失等 +> - ✅ **Block-based 架构共识**:4 种 Block 类型,R → Node.js → 前端全链路标准化 > > **部署状态:** ✅ 生产环境运行中 | 公网地址:http://8.140.53.236/ > **REDCap 状态:** ✅ 生产环境运行中 | 地址:https://redcap.xunzhengyixue.com/ @@ -70,7 +71,7 @@ | **ASL** | AI智能文献 | 文献筛选、Meta分析、证据图谱 | ⭐⭐⭐⭐⭐ | 🎉 **智能检索MVP完成(60%)** - DeepSearch集成 | **P0** | | **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** | | **IIT** | IIT Manager Agent | AI驱动IIT研究助手 - 双脑架构+REDCap集成 | ⭐⭐⭐⭐⭐ | 🎉 **事件级质控V3.1完成(设计100%,代码60%)** | **P0** | -| **SSA** | 智能统计分析 | Brain-Hand架构 + 统计护栏 + HITL + 10工具MVP | ⭐⭐⭐⭐⭐ | 🎉 **T检验端到端通过(设计100%,开发85%)** - Phase 1 核心完成 | **P1** | +| **SSA** | 智能统计分析 | Brain-Hand架构 + 统计护栏 + HITL + 10工具MVP | ⭐⭐⭐⭐⭐ | 🎉 **Phase 2A完成(设计100%,开发95%)** - 多步骤工作流+V11 UI+Block-based架构共识 | **P1** | | **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 | | **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探(L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V2.0 Week3完成(85%)** - 统计验证扩展+负号归一化+文件格式提示+用户体验优化 | P1 | | **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成(88%)** - Prompt知识库集成+动态注入 | **P0** | @@ -155,9 +156,25 @@ --- -## 🚀 当前开发状态(2026-02-19) +## 🚀 当前开发状态(2026-02-20) -### 🎉 最新进展:SSA Week 1 开发完成 + R Docker 构建成功(2026-02-19) +### 🎉 最新进展:SSA Phase 2A 前端集成完成 + Block-based 架构共识(2026-02-20) + +#### ✅ SSA Phase 2A 前端集成(2026-02-20) + +**核心成就**: +- ✅ **多步骤工作流端到端**:数据上传 → 质量报告 → 多步骤规划 → SSE 实时执行 → 结果 → 导出 +- ✅ **V11 UI 前后端联调**:Gemini 风格 + 多任务 + 单页滚动 + Word/R代码导出 +- ✅ **Python 数据质量服务**:CSV 直传 Python 解析,双端点支持 +- ✅ **意图识别增强**:变量名提取 + 类型判断 → 智能选择统计方法 +- ✅ **描述性统计**:专用渲染组件 + Word 导出完整支持 +- ✅ **6 个 Bug 修复**:SAP 误显示、布局混乱、SSE 卡死、结果丢失等 + +**架构决策**: +- ✅ **Block-based 动态渲染协议**:4 种 Block 类型(markdown/table/image/key_value) +- ✅ **开发计划已制定**:`08-Block-based动态结果渲染开发计划.md`(~2.5 天工时) + +### 🎉 SSA Week 1 开发完成 + R Docker 构建成功(2026-02-19) #### ✅ 实时质控系统 + 管理端批量操作 + 质控驾驶舱设计 @@ -1410,8 +1427,8 @@ npm run dev # http://localhost:3000 ### 模块完成度 - ✅ **已完成**:AIA V2.0(85%,核心功能完成)、平台基础层(100%)、RVW(95%)、通用能力层升级(100%)、**PKB(95%,Dify已替换)** 🎉 -- 🚧 **开发中**:ASL(80%)、DC(Tool C 98%,Tool B后端100%,Tool B前端0%)、IIT(60%,Phase 1.5完成) -- 📋 **未开始**:SSA、ST +- 🚧 **开发中**:ASL(80%)、DC(Tool C 98%,Tool B后端100%,Tool B前端0%)、IIT(60%,Phase 1.5完成)、**SSA(95%,Phase 2A完成,Block-based重构待执行)** 🎉 +- 📋 **未开始**:ST ### 部署完成度 - ✅ **基础设施**:VPC(100%)、NAT网关(100%)、安全组(100%) diff --git a/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md b/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md index 73dc4183..8fba0f8b 100644 --- a/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md +++ b/docs/02-通用能力层/06-R统计引擎/01-R统计引擎架构与部署指南.md @@ -1,9 +1,9 @@ # R 统计引擎架构与部署指南 -> **版本:** v1.0 -> **创建日期:** 2026-02-19 +> **版本:** v1.1 +> **更新日期:** 2026-02-20 > **维护者:** SSA-Pro 开发团队 -> **状态:** ✅ 生产就绪 +> **状态:** ✅ 生产就绪(Phase 2A 完成) --- @@ -109,6 +109,38 @@ R 统计引擎采用 **Brain-Hand 分离架构**: } ``` +#### 2.2.1 inline 数据格式详解 + +R 数据加载器 (`utils/data_loader.R`) 支持两种 JSON 数据格式: + +| 格式 | 说明 | 示例 | +|------|------|------| +| **行格式** | JSON 对象数组,每个对象是一行 | `[{"sex": 1, "age": 25}, {"sex": 2, "age": 30}]` | +| **列格式** | JSON 对象,每个属性是一列 | `{"sex": [1, 2], "age": [25, 30]}` | + +> **推荐**:使用**行格式**,与 JavaScript/TypeScript 的数据处理习惯一致。 + +**Node.js 调用示例:** +```typescript +// 推荐:行格式(Array of Objects) +const data = [ + { sex: 1, age: 25, bmi: 22.5 }, + { sex: 2, age: 30, bmi: 24.1 }, + // ... +]; + +const response = await axios.post('http://localhost:8082/api/v1/skills/ST_T_TEST_IND', { + data_source: { + type: 'inline', + data: data // 直接传入数组 + }, + params: { + group_var: 'sex', + value_var: 'age' + } +}); +``` + ### 2.3 安全设计 | 安全措施 | 实现方式 | @@ -241,8 +273,6 @@ ssa-r-statistics 1.0.1 xxxxxxxxxxxx x minutes ago 1.81GB ```yaml # r-statistics-service/docker-compose.yml -version: '3.8' - services: ssa-r-service: build: . @@ -253,12 +283,13 @@ services: - DEV_MODE=true volumes: # 开发环境挂载:支持热重载 + - ./plumber.R:/app/plumber.R # ⚠️ 重要:API 入口也需要挂载 - ./tools:/app/tools - ./utils:/app/utils - ./tests:/app/tests restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] # 容器内部仍是8080 + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 @@ -271,6 +302,19 @@ cd r-statistics-service docker-compose up -d ``` +#### 4.1.1 热重载机制详解 + +| 文件类型 | 热重载支持 | 说明 | +|----------|-----------|------| +| `tools/*.R` | ✅ 自动 | DEV_MODE=true 时每次请求重新加载 | +| `utils/*.R` | ⚠️ 需重启 | 服务启动时加载,修改后需 `docker-compose restart` | +| `plumber.R` | ⚠️ 需重启 | API 路由定义,修改后需 `docker-compose restart` | + +**最佳实践:** +- 开发新工具时,只需修改 `tools/` 目录,无需重启 +- 修改 `utils/` 或 `plumber.R` 后,执行 `docker-compose restart` +- 添加新的 API 端点后,需要 `docker-compose up -d --force-recreate` + ### 4.2 生产环境 (SAE) ```yaml @@ -335,11 +379,31 @@ GET /api/v1/tools ```json { "status": "ok", - "tools": ["t_test_ind", "anova_oneway"], - "count": 2 + "tools": [ + "chi_square", + "correlation", + "descriptive", + "logistic_binary", + "mann_whitney", + "t_test_ind", + "t_test_paired" + ], + "count": 7 } ``` +#### 已实现的统计工具(Phase 2A) + +| tool_code | 名称 | 场景 | +|-----------|------|------| +| `ST_T_TEST_IND` | 独立样本 T 检验 | 两组连续变量比较(正态) | +| `ST_MANN_WHITNEY` | Mann-Whitney U | 两组连续变量比较(非参数) | +| `ST_T_TEST_PAIRED` | 配对 T 检验 | 前后对比 | +| `ST_CHI_SQUARE` | 卡方检验 | 分类变量关联 | +| `ST_CORRELATION` | 相关分析 | Pearson/Spearman 相关 | +| `ST_LOGISTIC_BINARY` | 二元 Logistic 回归 | 多因素分析 | +| `ST_DESCRIPTIVE` | 描述性统计 | 基线表、数据概况 | + ### 5.3 执行技能 ```http @@ -394,6 +458,59 @@ Content-Type: application/json } ``` +### 5.4 JIT 护栏检查(Phase 2A 新增) + +在执行核心统计工具前,调用此端点检验统计假设(正态性、方差齐性等)。 + +```http +POST /api/v1/guardrails/jit +Content-Type: application/json +``` + +**请求体:** +```json +{ + "data_source": { + "type": "inline", + "data": [...] + }, + "tool_code": "ST_T_TEST_IND", + "params": { + "group_var": "sex", + "value_var": "age" + } +} +``` + +**响应:** +```json +{ + "status": "success", + "checks": [ + { + "check_name": "正态性检验 (组: 1)", + "passed": true, + "p_value": 0.234, + "recommendation": "满足正态性" + }, + { + "check_name": "方差齐性检验 (Levene)", + "passed": false, + "p_value": 0.012, + "recommendation": "建议使用 Welch 校正" + } + ], + "suggested_tool": "ST_MANN_WHITNEY", + "can_proceed": true, + "all_checks_passed": false +} +``` + +**使用场景:** +- 工作流执行器在调用核心统计方法前,先调用 JIT 护栏 +- 根据 `suggested_tool` 自动切换到更合适的方法 +- 将 `checks` 结果展示给用户 + --- ## 6. 开发指南 @@ -408,37 +525,106 @@ Content-Type: application/json #' @tool_code ST_MY_ANALYSIS #' @name 我的分析工具 #' @version 1.0.0 +#' @description 工具描述 +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) -# 统一入口函数 run_analysis <- function(input) { - # 加载数据 - df <- load_input_data(input) + # ===== 初始化日志 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } - # 参数 + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + # ===== 参数提取 ===== p <- input$params + my_var <- p$my_var - # 护栏检查 - # ... + # ===== 参数校验 ===== + if (!(my_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = my_var)) + } - # 核心计算 - # ... + # ===== 护栏检查 ===== + guardrail_results <- list() + warnings_list <- c() + + sample_check <- check_sample_size(nrow(df), min_required = 10, action = ACTION_WARN) + guardrail_results <- c(guardrail_results, list(sample_check)) + + guardrail_status <- run_guardrail_chain(guardrail_results) + if (guardrail_status$status == "blocked") { + return(list(status = "blocked", message = guardrail_status$reason, trace_log = logs)) + } + + # ===== 核心计算 ===== + log_add("执行分析...") + # result <- your_analysis_function(df, ...) + + # ===== 生成图表 ===== + plot_base64 <- tryCatch({ + p <- ggplot(df, aes(x = df[[my_var]])) + geom_histogram() + theme_minimal() + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 7, height = 5, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + paste0("data:image/png;base64,", base64_str) + }, error = function(e) NULL) + + # ===== 生成可复现代码 ===== + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: 我的分析工具 +# 时间: {Sys.time()} +# ================================ + +df <- read.csv("data.csv") +# 你的分析代码... +') + + # ===== 返回结果 ===== + log_add("分析完成") - # 返回结果 return(list( status = "success", message = "分析完成", - results = list(...) + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = list( + # 统计结果(使用 jsonlite::unbox 保证单值不被包装成数组) + statistic = jsonlite::unbox(1.234), + p_value = jsonlite::unbox(0.05), + p_value_fmt = format_p_value(0.05) + ), + plots = if (!is.null(plot_base64)) list(plot_base64) else list(), + trace_log = logs, + reproducible_code = as.character(reproducible_code) )) } ``` -2. 重启服务(开发模式无需重启) +2. **开发模式**:修改 `tools/` 下的文件后,无需重启,下次请求自动加载 3. 测试: ```bash curl -X POST http://localhost:8082/api/v1/skills/ST_MY_ANALYSIS \ -H "Content-Type: application/json" \ - -d '{"data_source": {...}, "params": {...}}' + -d '{"data_source": {"type": "inline", "data": [{"x": 1}, {"x": 2}]}, "params": {"my_var": "x"}}' ``` ### 6.2 工具命名规范 @@ -550,6 +736,122 @@ volumes: 2. 健康检查是否通过 3. 查看容器日志 +### Q6: 数据加载失败(inline 模式) + +**错误:** `内部错误: 数据加载失败` + +**原因:** 数据格式不正确,或数据为空 + +**解决:** +1. 确保 `data_source.data` 是有效的 JSON 数组 +2. 行格式:`[{"col1": val1}, {"col1": val2}]` +3. 检查是否有空数据或全 NA 列 + +### Q7: R 脚本语法错误 + +**错误:** `unexpected symbol` 或 `lexical error` + +**常见原因:** +1. `glue()` 字符串中使用 `\'` 转义(应直接使用 `'`) +2. 中文注释编码问题 +3. 代码块中的花括号不匹配 + +**解决:** +```r +# 错误:glue 中的转义 +glue("# Cramer\'s V = ...") # ❌ + +# 正确:直接使用单引号或避免 +glue("# Cramer V = ...") # ✅ +``` + +### Q8: JSON 序列化失败 + +**错误:** `No method asJSON S3 class: table` + +**原因:** R 的 `table` 对象无法直接序列化为 JSON + +**解决:** +```r +# 错误 +observed = as.matrix(contingency_table) # ❌ 可能保留 table 属性 + +# 正确:显式转换为纯数值矩阵 +observed = matrix( + as.numeric(contingency_table), + nrow = nrow(contingency_table), + ncol = ncol(contingency_table) +) # ✅ +``` + +### Q9: 新端点返回 404 + +**原因:** 修改 `plumber.R` 后未重启服务 + +**解决:** +```bash +# 修改 plumber.R 后必须重启 +docker-compose restart + +# 如果修改了 docker-compose.yml(如添加新 volume) +docker-compose up -d --force-recreate +``` + +### Q10: 变量类型判断错误(missing value where TRUE/FALSE needed) + +**原因:** 对包含 NA 的数据进行布尔比较 + +**解决:** +```r +# 错误 +if (var_type == "numeric") { ... } # var_type 可能是 NA + +# 正确 +if (identical(var_type, "numeric")) { ... } # ✅ 处理 NA +``` + +--- + +## 9. 测试指南 + +### 9.1 单工具测试 + +```bash +# 测试 T 检验 +curl -s -X POST "http://localhost:8082/api/v1/skills/ST_T_TEST_IND" \ + -H "Content-Type: application/json" \ + -d '{ + "data_source": { + "type": "inline", + "data": [ + {"group": "A", "value": 23}, {"group": "A", "value": 25}, + {"group": "B", "value": 30}, {"group": "B", "value": 32} + ] + }, + "params": {"group_var": "group", "value_var": "value"} + }' +``` + +### 9.2 健康检查 + +```bash +curl -s http://localhost:8082/health | jq +``` + +### 9.3 端到端测试脚本 + +项目提供了完整的端到端测试脚本: + +```bash +cd docs/03-业务模块/SSA-智能统计分析/05-测试文档 +node run_e2e_test.js +``` + +测试覆盖: +- 7 个统计工具 +- JIT 护栏检查 +- 数据加载(行格式/列格式) + --- ## 附录:文件结构 @@ -557,17 +859,23 @@ volumes: ``` r-statistics-service/ ├── Dockerfile # 生产镜像定义 -├── docker-compose.yml # 开发环境编排 +├── docker-compose.yml # 开发环境编排(含 volume 挂载) ├── renv.lock # R 包版本锁定(备用) ├── .Rprofile # R 启动配置(备用) -├── plumber.R # API 入口 +├── plumber.R # API 入口(含 JIT 护栏端点) ├── utils/ -│ ├── data_loader.R # 数据加载(预签名 URL) -│ ├── guardrails.R # 统计护栏 +│ ├── data_loader.R # 数据加载(支持行格式/列格式) +│ ├── guardrails.R # 统计护栏 + JIT 检查 │ ├── error_codes.R # 错误映射 │ └── result_formatter.R # 结果格式化 -├── tools/ -│ └── t_test_ind.R # 独立样本 T 检验 +├── tools/ # 统计工具(Phase 2A: 7 个) +│ ├── t_test_ind.R # 独立样本 T 检验 +│ ├── t_test_paired.R # 配对 T 检验 +│ ├── mann_whitney.R # Mann-Whitney U 检验 +│ ├── chi_square.R # 卡方检验 +│ ├── correlation.R # 相关分析 +│ ├── logistic_binary.R # 二元 Logistic 回归 +│ └── descriptive.R # 描述性统计 ├── tests/ │ └── fixtures/ │ └── normal_data.csv # 测试数据 @@ -577,4 +885,13 @@ r-statistics-service/ --- +## 更新日志 + +| 版本 | 日期 | 更新内容 | +|------|------|----------| +| v1.1 | 2026-02-20 | Phase 2A 完成:7 个统计工具、JIT 护栏、热重载说明、常见问题补充 | +| v1.0 | 2026-02-19 | 初始版本:架构设计、部署指南、T 检验工具 | + +--- + **文档结束** diff --git a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md index 5df9be32..8042c02f 100644 --- a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md @@ -1,10 +1,10 @@ # SSA智能统计分析模块 - 当前状态与开发指南 -> **文档版本:** v1.6 +> **文档版本:** v1.7 > **创建日期:** 2026-02-18 > **最后更新:** 2026-02-20 > **维护者:** 开发团队 -> **当前状态:** 🎉 **V11 UI 前后端联调测试通过!MVP Phase 1 核心完成 95%** +> **当前状态:** 🎉 **Phase 2A 前端集成完成!多步骤工作流端到端通过 + Block-based 架构共识达成** > **文档目的:** 快速了解SSA模块状态,为新AI助手提供上下文 > > **🎉 里程碑(2026-02-18):** @@ -22,14 +22,19 @@ > - ✅ **前端模块激活**:智能统计分析入口可用 > - ✅ **用户会话隔离**:不同用户数据正确隔离 > -> **🎉 V11 UI 前后端联调测试通过(2026-02-20):** -> - ✅ **🎉 V11 UI 像素级还原**:Gemini 风格对话界面,全屏沉浸式体验 +> **🎉 V11 UI 前后端联调测试通过(2026-02-20 上午):** +> - ✅ **V11 UI 像素级还原**:Gemini 风格对话界面,全屏沉浸式体验 > - ✅ **多任务支持**:单会话内可执行多个分析任务,独立管理状态 > - ✅ **单页滚动布局**:分析计划 → 执行日志 → 分析结果,步骤进度条导航 > - ✅ **Word 报告导出**:完整统计报告,包含数据描述、方法、结果、图表、结论 -> - ✅ **输入框遮挡修复**:Scroll Spacer 方案,解决 Flexbox padding-bottom 问题 -> - ✅ **代码清理完成**:删除旧版 V8/V9 组件,精简代码结构 -> - ✅ **前后端完整联调**:数据上传 → 计划生成 → 执行分析 → 结果展示 → 报告导出 +> +> **🆕 🎉 Phase 2A 前端集成完成(2026-02-20 下午):** +> - ✅ **多步骤工作流端到端**:数据上传 → 质量报告 → 多步骤规划 → SSE 实时执行 → 结果展示 → 报告/代码导出 +> - ✅ **Python 数据质量服务集成**:CSV 直传 Python 解析,修复端口/环境变量问题 +> - ✅ **意图识别增强**:正则提取变量名 + 变量类型判断 → 智能选择统计方法 +> - ✅ **描述性统计完整支持**:专用 DescriptiveResultView 组件 + Word 导出 +> - ✅ **6 个前端 Bug 修复**:SAP 误显示、布局混乱、SSE 卡死、结果丢失等 +> - ✅ **Block-based 架构共识达成**:4 种 Block 类型,R 输出标准化 → Node.js 零维护 → 前端动态渲染 > > **🆕 v1.5 新增特性(专家配置体系):** > - 🆕 **统计决策表**:(Goal, Y, X, Design) 四维匹配精准选工具,替代简单 RAG @@ -51,7 +56,7 @@ | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **独立性** | ⭐⭐⭐⭐ 高(可独立使用,也可与其他模块协同) | | **目标用户** | 临床研究人员、生物统计师 | -| **开发状态** | 🚀 **MVP Phase 1 开发中(Week 1 完成 ~95%)** | +| **开发状态** | 🎉 **Phase 2A 完成,多步骤工作流端到端通过(开发 ~95%)** | ### 核心目标 @@ -175,6 +180,7 @@ | **代码生成** | glue 模板 | 可维护性好于 paste0 | | **LLM 输出** | jsonrepair + Zod | 容错 JSON 解析 | | **隐私保护** | Schema 脱敏 + 分类变量稀有值隐藏 | 防止 LLM 泄露数据 | +| **🆕 结果渲染** | Block-based 协议(4 种 Block) | R 端标准化输出 → Node.js 零维护 → 前端动态渲染 | --- @@ -184,18 +190,20 @@ |-------|------|------|---------| | Phase 0 | 需求分析与架构设计 | ✅ 已完成 | 2026-02-18 | | Phase 0 | MVP 开发计划 v1.0 → v1.6 | ✅ 已完成 | 2026-02-19 | -| Phase 1 | 骨架搭建 + 配置中台 | 🎉 **核心完成 85%** | 2026-02-19 | +| Phase 1 | 骨架搭建 + 配置中台 | ✅ **核心完成 95%** | 2026-02-19 | +| Phase 2A | 智能核心(多步骤工作流 + 意图识别) | ✅ **前端集成完成** | 2026-02-20 | +| Phase 2B | Block-based 动态渲染重构 | 📋 计划已制定 | - | | Phase 2 | 智能规划 + 咨询模式 | 📋 待开始 | - | | Phase 3 | 完善与联调 | 📋 待开始 | - | -| **总计** | 106 个任务 | **完成 38 项(36%)** | - | -### 🎉 Phase 1 已完成核心功能 +### 🎉 已完成核心功能 | 组件 | 完成项 | 状态 | |------|--------|------| -| **R 服务** | T 检验、错误码、护栏、预签名 URL、缺失值处理 | ✅ 100% | -| **后端** | 路由、RClientService、DataParserService、代码下载 | ✅ 83% | -| **前端** | 页面框架、数据上传、结果展示、代码下载、模块注册 | ✅ 100% | +| **R 服务** | T 检验、描述性统计、卡方检验、Logistic 回归、相关分析、错误码、护栏 | ✅ 100% | +| **后端** | 路由、RClientService、DataProfileService、WorkflowPlannerService、WorkflowExecutorService、SSE | ✅ 95% | +| **前端** | V11 UI、多步骤工作流、DescriptiveResultView、R 代码/Word 导出、SSE 实时进度 | ✅ 95% | +| **Python 服务** | 数据质量分析(JSON + CSV 双端点) | ✅ 100% | | **配置中台** | 数据库表、热加载 API | 🔄 18% | ### 开发计划文档 @@ -207,6 +215,7 @@ | **R服务指南** | `04-开发计划/02-R服务开发指南.md` | R 统计工程师专用 | | **后端指南** | `04-开发计划/03-后端开发指南.md` | Node.js 工程师专用 | | **前端指南** | `04-开发计划/04-前端开发指南.md` | 前端工程师专用 | +| **🆕 Block-based 渲染** | `04-开发计划/08-Block-based动态结果渲染开发计划.md` | 动态结果渲染架构重构 | ### 评审记录 @@ -401,6 +410,9 @@ GET http://localhost:3001/api/v1/ssa/config/tools ### MVP 阶段(当前) - [x] Phase 1:骨架搭建(T检验端到端跑通)✅ 2026-02-19 +- [x] Phase 1.5:V11 UI 前后端联调 ✅ 2026-02-20 +- [x] Phase 2A:智能核心(多步骤工作流 + 前端集成)✅ 2026-02-20 +- [ ] Phase 2B:Block-based 动态渲染重构(~2.5 天) - [ ] Phase 2:智能与交互(RAG + HITL + 10工具) - [ ] Phase 3:打磨与调试(性能优化 + Bug修复) @@ -413,7 +425,7 @@ GET http://localhost:3001/api/v1/ssa/config/tools --- -**文档版本:** v1.5 -**最后更新:** 2026-02-19 -**当前状态:** 🎉 T 检验端到端测试通过,Phase 1 核心完成 85% -**下一步:** Phase 2 智能规划 + 更多统计方法(或完善配置中台) +**文档版本:** v1.7 +**最后更新:** 2026-02-20 +**当前状态:** 🎉 Phase 2A 前端集成完成,多步骤工作流端到端通过 +**下一步:** Phase 2B Block-based 动态渲染重构(4 种 Block 类型 → R/Node.js/前端全链路改造) diff --git a/docs/03-业务模块/SSA-智能统计分析/00-系统设计/SSA-Pro 愿景与开发计划对比分析.md b/docs/03-业务模块/SSA-智能统计分析/00-系统设计/SSA-Pro 愿景与开发计划对比分析.md new file mode 100644 index 00000000..7caf1157 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/00-系统设计/SSA-Pro 愿景与开发计划对比分析.md @@ -0,0 +1,447 @@ +# SSA-Pro 愿景与开发计划对比分析 + +> **文档版本:** v1.0 +> **创建日期:** 2026-02-20 +> **文档目的:** 对比理想愿景与现有开发计划,识别差距,明确下一步方向 + +--- + +## 1. 对比概览 + +### 1.1 两份文档的定位 + +| 文档 | 定位 | 视角 | +|------|------|------| +| **理想状态与智能化愿景设计** | 目标终态 | 以终为始,用户视角 | +| **MVP开发计划总览** | 实施路径 | 工程视角,分阶段交付 | + +### 1.2 核心差异一句话总结 + +| 维度 | 愿景设计 | 开发计划 | +|------|----------|----------| +| **核心理念** | **规划流程** | **执行方法** | +| **执行粒度** | 多方法编排的完整流程 | 单个统计工具的执行 | +| **智能表现** | AI 理解意图、诊断数据、规划路径 | 决策表匹配选工具 | + +--- + +## 2. 详细对比分析 + +### 2.1 用户交互模式对比 + +#### 愿景设计的交互 + +``` +用户输入: +"我有 200 个高血压患者的数据,分成治疗组和对照组, + 想比较治疗前后的血压变化,看看新药是否有效。" + +系统响应: +Step 1: 意图解析 → 识别为"差异比较" +Step 2: 数据诊断 → 发现正态性不满足 +Step 3: 规划流程 → 生成 6 步 SAP +Step 4: 分步执行 → 依次运行 +Step 5: 综合结论 → 论文级报告 +``` + +#### 开发计划的交互 + +``` +用户操作: +1. 上传数据 +2. 输入分析需求 +3. 系统生成计划(选一个工具) +4. 用户确认执行 +5. 返回结果 + +系统响应: +- Planner 选择 ST_T_TEST_IND +- Executor 执行 T 检验 +- 返回 P 值 + 图表 + R 代码 +``` + +#### 差异分析 + +| 维度 | 愿景设计 | 开发计划 | 差距 | +|------|----------|----------|------| +| 用户输入 | 自然语言描述研究问题 | 需要较明确的分析需求 | 中 | +| 方法选择 | AI 理解意图 + 数据诊断 | 决策表匹配 | 中 | +| 执行粒度 | 6 步完整流程 | 1 个方法 | 🔴 大 | +| 输出形式 | 论文级综合报告 | P 值 + 图表 | 🔴 大 | + +--- + +### 2.2 系统架构对比 + +#### 愿景设计的架构(5 大核心组件) + +``` +┌─────────────────┐ +│ 1. 意图理解器 │ ← LLM 意图识别 +└────────┬────────┘ + ↓ +┌─────────────────┐ +│ 2. 数据诊断器 │ ← 分布/缺失/异常检测 +└────────┬────────┘ + ↓ +┌─────────────────┐ +│ 3. 路径规划器 │ ← 决策表 + 流程模板 ⭐ +└────────┬────────┘ + ↓ +┌─────────────────┐ +│ 4. 流程执行器 │ ← 多方法编排 ⭐ +└────────┬────────┘ + ↓ +┌─────────────────┐ +│ 5. 结论生成器 │ ← 论文级综合结论 +└─────────────────┘ +``` + +#### 开发计划的架构(Planner + Executor + 配置中台) + +``` +┌─────────────────────────────────────────┐ +│ Planner (大脑) │ +│ Rewriter → 决策表匹配 → Planner → Critic │ +└────────────────┬────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Executor (四肢) │ +│ 护栏检查 → 核心计算 → 代码生成 │ +└────────────────┬────────────────────────┘ + ↑ +┌────────────────┴────────────────────────┐ +│ 配置中台 (专家知识库) │ +│ 决策表 + R代码库 + 参数映射 + 护栏规则 │ +└─────────────────────────────────────────┘ +``` + +#### 架构对比表 + +| 愿景组件 | 开发计划对应 | 覆盖程度 | 备注 | +|----------|-------------|----------|------| +| 意图理解器 | Rewriter + 决策表匹配 | 🟡 50% | 开发计划侧重四维匹配,缺少意图追问 | +| 数据诊断器 | R 服务内护栏检查 | 🟡 60% | 已有基础,但未独立成模块 | +| 路径规划器 | Planner + 决策表 | 🔴 30% | **关键差距:只选单个工具,不规划流程** | +| 流程执行器 | Executor | 🔴 20% | **关键差距:只执行单个方法,无编排能力** | +| 结论生成器 | Critic | 🟡 50% | 已有解读模板,但缺少综合整合 | + +--- + +### 2.3 核心能力对比 + +#### 2.3.1 方法选择能力 + +| 能力 | 愿景设计 | 开发计划 | 是否覆盖 | +|------|----------|----------|----------| +| 四维匹配 (Goal/Y/X/Design) | ✅ | ✅ | ✅ 已覆盖 | +| 意图追问澄清 | ✅ | ❌ | ❌ 未覆盖 | +| 基于数据特征调整 | ✅ | ✅ (护栏) | ✅ 已覆盖 | + +#### 2.3.2 执行能力 + +| 能力 | 愿景设计 | 开发计划 | 是否覆盖 | +|------|----------|----------|----------| +| 单方法执行 | ✅ | ✅ | ✅ 已覆盖 | +| 护栏检查 | ✅ | ✅ | ✅ 已覆盖 | +| 自动降级 (Switch) | ✅ | ✅ | ✅ 已覆盖 | +| **多方法编排** | ✅ | ❌ | 🔴 **未覆盖** | +| **结果串联** | ✅ | ❌ | 🔴 **未覆盖** | +| **分步展示** | ✅ | ❌ | 🔴 **未覆盖** | + +#### 2.3.3 输出能力 + +| 能力 | 愿景设计 | 开发计划 | 是否覆盖 | +|------|----------|----------|----------| +| 统计结果表格 | ✅ | ✅ | ✅ 已覆盖 | +| 可视化图表 | ✅ | ✅ | ✅ 已覆盖 | +| R 代码下载 | ✅ | ✅ | ✅ 已覆盖 | +| 简单解读 | ✅ | ✅ | ✅ 已覆盖 | +| **论文级综合结论** | ✅ | ❌ | 🔴 **未覆盖** | +| **方法学说明** | ✅ | ❌ | 🔴 **未覆盖** | +| **敏感性分析结果** | ✅ | ❌ | 🔴 **未覆盖** | + +--- + +### 2.4 流程模板 vs 单方法执行(关键差距) + +这是两份文档最核心的差异。 + +#### 愿景设计的流程模板 + +``` +用户需求: "比较两组疗效差异" + +系统规划的完整流程: +┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ +│ 1.数据 │ 2.描述 │ 3.假设 │ 4.敏感性│ 5.效应量│ 6.可视化│ +│ 预处理 │ 统计 │ 检验 │ 分析 │ │ │ +└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ + ↓ ↓ ↓ ↓ ↓ ↓ + 剔除异常 均值±SD T检验 Bootstrap Cohen's d 箱线图 + 处理缺失 中位数 /Wilcoxon T检验 95%CI +``` + +#### 开发计划的单方法执行 + +``` +用户需求: "比较两组疗效差异" + +系统执行: +┌─────────────────────────────────────────┐ +│ ST_T_TEST_IND │ +│ 护栏检查 → T 检验 → 结果返回 │ +└─────────────────────────────────────────┘ +``` + +#### 差距影响分析 + +| 缺失的步骤 | 对用户的影响 | 对论文的影响 | +|------------|-------------|-------------| +| 数据预处理说明 | 不知道剔除了多少数据 | 无法写方法部分 | +| 描述性统计 | 缺少基线特征表 | 缺少 Table 1 | +| 敏感性分析 | 不知道结果是否稳健 | 审稿人会质疑 | +| 效应量计算 | 不知道临床意义 | 缺少临床解读 | + +--- + +## 3. 相同之处 + +### 3.1 核心理念一致 + +| 理念 | 愿景设计 | 开发计划 | +|------|----------|----------| +| **白盒化** | 用户理解 AI 做了什么 | 执行路径可视化 | +| **严谨性** | 统计护栏防止滥用 | 护栏规则链 | +| **可交付** | 生成可复现的 R 代码 | 代码下载功能 | + +### 3.2 技术选型一致 + +| 技术 | 愿景设计 | 开发计划 | +|------|----------|----------| +| Brain-Hand 分离 | ✅ | ✅ | +| LLM + R 服务 | ✅ | ✅ | +| 四维匹配 (Goal/Y/X/Design) | ✅ | ✅ | +| 护栏检查 | ✅ | ✅ | +| 代码生成 | ✅ | ✅ | + +### 3.3 配置中台的价值 + +配置中台在两份文档中都被认可: + +| 配置项 | 愿景设计中的作用 | 开发计划中的作用 | +|--------|-----------------|-----------------| +| 决策表 | 方法选择的依据 | 工具匹配的规则 | +| 流程模板 | **规划完整分析流程** | ❌ 未涉及 | +| 护栏规则 | 数据自适应 | Block/Warn/Switch | +| 解读模板 | 论文级结论生成 | 结果解读 | + +--- + +## 4. 能否达到理想状态? + +### 4.1 结论:按现有计划执行,**无法达到理想状态** + +| 理想目标 | 现有计划能否达成 | 原因 | +|----------|-----------------|------| +| AI 理解用户意图 | 🟡 部分 | 有决策表匹配,但缺少意图追问 | +| 数据自动诊断 | 🟡 部分 | R 服务内有护栏,但未独立呈现 | +| 规划完整分析流程 | 🔴 无法 | **计划只做单方法选择,无流程模板** | +| 多方法编排执行 | 🔴 无法 | **计划只做单方法执行,无编排能力** | +| 论文级综合报告 | 🔴 无法 | **计划只做简单解读,无综合整合** | + +### 4.2 根本原因分析 + +现有开发计划的核心思路是: + +``` +配置中台 → 支撑 10 个工具的可配置化 → 扩展更多方法 +``` + +但理想状态需要的是: + +``` +流程引擎 → 将多个方法编排成完整分析流程 → 真正的智能化 +``` + +**配置中台是基础设施,但不是智能化的核心。** + +--- + +## 5. 还缺什么? + +### 5.1 缺失的核心组件 + +| 组件 | 功能 | 开发计划中是否有 | +|------|------|-----------------| +| **流程模板定义** | 定义"差异比较流程"包含哪些步骤 | ❌ 完全没有 | +| **流程执行引擎** | 按顺序编排多个方法执行 | ❌ 完全没有 | +| **结果串联器** | 上一步输出作为下一步输入 | ❌ 完全没有 | +| **综合结论生成器** | 整合多步结果生成完整报告 | ❌ 完全没有 | +| **意图追问模块** | 不确定时向用户澄清 | ❌ 完全没有 | + +### 5.2 需要增强的组件 + +| 组件 | 当前状态 | 需要增强 | +|------|----------|----------| +| 数据诊断器 | R 服务内部 | 提取为独立模块,前端可视化展示 | +| Planner 输出 | 输出 tool_code | 输出 workflow_steps[] | +| 结论生成 | 简单解读 | 论文级模板 + 方法学说明 | + +### 5.3 差距可视化 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 理想状态 (100%) │ +├─────────────────────────────────────────────────────────────┤ +│ ████████████████████████████████████████████████████████████│ +│ 意图理解 │ 数据诊断 │ 流程规划 │ 流程执行 │ 综合结论 │ +│ 10% │ 10% │ 25% │ 25% │ 30% │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 开发计划覆盖 (~40%) │ +├─────────────────────────────────────────────────────────────┤ +│ ██████████████████████████ │ +│ 意图理解 │ 数据诊断 │ 流程规划 │ 流程执行 │ 综合结论 │ +│ ✅ 5% │ ✅ 6% │ ❌ 8% │ ❌ 5% │ ✅ 15% │ +│ 决策表 │ 护栏 │ 只选工具│ 单方法 │ 简单解读 │ +└─────────────────────────────────────────────────────────────┘ + +未覆盖部分 (~60%): +- 流程规划:17% (流程模板定义) +- 流程执行:20% (多方法编排、结果串联) +- 综合结论:15% (论文级报告、方法学说明) +- 意图理解:5% (追问澄清) +- 数据诊断:4% (独立展示) +``` + +--- + +## 6. 建议的调整方向 + +### 6.1 重新定义开发重点 + +| 原计划重点 | 建议调整为 | +|------------|-----------| +| 配置中台完善 | 流程引擎建设 | +| 10 个工具量产 | 先做好 2-3 个完整流程 | +| 专家配置体系 | 流程模板 + 方法编排 | + +### 6.2 建议的新 Phase 规划 + +``` +Phase 1.5: 流程引擎 MVP(建议新增) +├── 1. 定义流程模板数据结构 +├── 2. 实现流程执行引擎(多方法编排) +├── 3. 实现结果串联(上一步 → 下一步) +├── 4. 前端展示分步进度 +└── 5. 验证:一个完整的"两组差异比较"流程 + +Phase 2: 智能规划(调整) +├── 原:决策表驱动规划 +├── 新:决策表 → 选择流程模板 → 生成完整 SAP +└── 新:意图追问模块 + +Phase 3: 完善与扩展(调整) +├── 原:10 个工具 +└── 新:3-5 个完整分析流程 +``` + +### 6.3 流程模板示例 + +```typescript +// 流程模板数据结构 +interface WorkflowTemplate { + id: string; + name: string; // "两组差异比较" + applicableTo: { // 适用条件 + goal: 'Difference'; + yType: 'Continuous'; + xType: 'Categorical_2'; + }; + steps: WorkflowStep[]; +} + +interface WorkflowStep { + stepId: string; + name: string; // "正态性检验" + toolCode: string; // "ST_SHAPIRO" 或 "ST_T_TEST_IND" + isConditional: boolean; // 是否有条件分支 + conditions?: { // 条件分支 + if: string; // "shapiro.pValue < 0.05" + then: string; // "ST_MANN_WHITNEY" + else: string; // "ST_T_TEST_IND" + }; + inputFrom?: string; // 上一步的输出作为输入 +} +``` + +### 6.4 投入产出分析 + +| 投入 | 预估工时 | 产出价值 | +|------|----------|----------| +| 流程模板定义 | 2-3 天 | 从单方法到完整流程的跨越 | +| 流程执行引擎 | 5-7 天 | 多方法编排能力 | +| 结果串联 | 2-3 天 | 数据在步骤间流转 | +| 综合结论生成 | 3-5 天 | 论文级报告输出 | +| **总计** | **12-18 天** | **达到理想状态的 80%** | + +--- + +## 7. 总结 + +### 7.1 核心结论 + +1. **理想状态 ≠ 配置中台**:配置中台是基础设施,不是智能化的核心。 + +2. **核心差距是"流程编排"**:现有计划是"单方法执行",理想状态是"多方法编排"。 + +3. **按现有计划无法达到理想状态**:缺少流程模板、流程引擎、结果串联、综合结论。 + +4. **需要新增 Phase 1.5**:在 Phase 2 之前,先建设"流程引擎"。 + +### 7.2 行动建议 + +| 优先级 | 行动 | 说明 | +|--------|------|------| +| P0 | 暂停配置中台开发 | 配置中台是锦上添花,不是雪中送炭 | +| P0 | 设计流程模板数据结构 | 这是一切的基础 | +| P0 | 实现流程执行引擎 | 让多个方法能够串联执行 | +| P1 | 实现一个完整流程 | "两组差异比较"从头到尾 | +| P2 | 扩展更多流程模板 | 基于成功经验复制 | + +### 7.3 一句话总结 + +> **现有开发计划做的是"让 10 个工具都能用", +> 但理想状态需要的是"让 1 个分析流程足够智能"。** +> +> **方向不同,结果自然不同。** + +--- + +## 8. 附录 + +### 8.1 相关文档 + +| 文档 | 路径 | +|------|------| +| 理想状态与智能化愿景设计 | `00-系统设计/SSA-Pro 理想状态与智能化愿景设计.md` | +| MVP 开发计划总览 | `04-开发计划/00-MVP开发计划总览.md` | +| 任务清单与进度追踪 | `04-开发计划/01-任务清单与进度追踪.md` | +| 架构设计方案 V4 | `00-系统设计/SSA-Pro 严谨型智能统计分析架构设计方案V4.md` | + +### 8.2 术语对照 + +| 愿景设计术语 | 开发计划术语 | 含义 | +|-------------|-------------|------| +| 流程模板 | - | 预定义的多步骤分析流程 | +| 流程执行器 | Executor | 执行引擎(计划只执行单方法) | +| 路径规划器 | Planner | 选择工具/流程(计划只选工具) | +| 综合结论 | Critic | 结果解读(计划是简单解读) | + +--- + +**文档维护者:** SSA 架构团队 +**创建日期:** 2026-02-20 +**版本:** v1.0 diff --git a/docs/03-业务模块/SSA-智能统计分析/03-UI设计/V12.html b/docs/03-业务模块/SSA-智能统计分析/03-UI设计/V12.html new file mode 100644 index 00000000..293eac5b --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/03-UI设计/V12.html @@ -0,0 +1,546 @@ + + + + + + SSA-Pro 智能统计工作台 V12.0 (Agentic Pipeline) + + + + + + + + + +
+ + + + + +
+ + +
+ + +
+
+ 血压下降疗效分析 + Multi-Agent +
+
+ Pipeline Ready +
+
+ + +
+
+ + +
+
+
+ 你好!我是 SSA-Pro 智能流水线
我由「意图理解」、「数据诊断」、「路径规划」等多个专家智能体组成。请点击下方 📎 上传数据文件 并描述需求,我们将协同为您服务。 +
+
+ + +
+ + +
+
+
+ + + +
+
+ + +
+ + + + + +
+ + + + + +
+
+
+
+
+ + +
+ +
+ + +
+
+ + Execution + + 流程执行器 (Pipeline Executor) +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+
+
+

R 编排引擎运行中

+

正在按 SAP 计划逐步执行统计检验...

+
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + + +
+
+
+
+
+ +
+ + + + \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/00-MVP开发计划总览.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/00-MVP开发计划总览.md index 235a3606..d5d91e18 100644 --- a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/00-MVP开发计划总览.md +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/00-MVP开发计划总览.md @@ -1,10 +1,61 @@ # SSA-Pro MVP 开发计划总览 -> **文档版本:** v1.5 +> **文档版本:** v1.7 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-18(纳入专家配置体系 + 决策表匹配 + R代码库) +> **最后更新:** 2026-02-20(纳入 Prompt 体系 + 多工具流程规划 + 数据质量核查报告) > **项目代号:** SSA (Smart Statistical Analysis) -> **MVP 目标:** 打通完整闭环,上线 10 个核心统计工具,支持咨询模式 +> **MVP 目标:** 打通完整闭环,上线 10 个核心统计工具,支持多工具流程规划 + 数据质量核查 + +--- + +## 0. 🆕 智能化演进愿景(战略定位) + +> **详细文档参考:** `04-开发计划/06-智能化演进共识与MVP执行计划.md` + +### 0.1 产品终极目标 + +> **SSA-Pro 的终极目标是 Level 3:颠覆性的智能分析助手** +> +> 让不懂统计的医生,能够完成专业级的统计分析。 + +### 0.2 三阶段演进路线 + +``` +Phase 1/2: 爬行期 (MVP) ← 当前阶段 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +目标:打透 Tool Calling,建立用户信任 +机制:100个工具作为固定API,LLM 做智能调度 +AI角色:高级调度员 / 全科主任医师 + +Phase 2.5: 行走期 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +目标:引入受限的代码智能 +机制:数据清洗允许 LLM 生成代码,核心统计仍用固定工具 + +Phase 3: 奔跑期 (终极愿景) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +目标:自愈型靶向代码修改 +机制:运行报错时,LLM 根据错误日志靶向修改工具代码 +关键:不是自由生成代码,而是基于现有工具的靶向修复 +``` + +### 0.3 MVP 阶段的"为未来铺路"任务 + +| 伏笔任务 | Phase 3 用途 | MVP 阶段行动 | +|---------|-------------|-------------| +| **Prompt 动态注入体系** | 支持专家配置热更新 | 🆕 骨架(AI工程师)+ 血肉(统计专家)分离 | +| **多工具流程规划** | 复杂场景的自动化 | 🆕 LLM 生成 2-7 步串联执行流程 | +| **数据质量核查报告** | 智能诊断异常数据 | 🆕 自动生成"数据体检报告"(评分 + 建议)| +| **Prompt 世界观** | LLM 理解自己可以修改代码 | Prompt 中强调"代码库"概念 | +| **向量库代码片段** | 便于 LLM 理解工具代码 | `tools_library` 存入核心代码片段 | +| **结构化错误日志** | 用于错误反馈自愈 | 建立 JSON 格式的错误捕获管道 | +| **黄金数据集积累** | 学习"什么错误如何修复" | 完整记录每次调用(含报错) | + +### 0.4 关键共识 + +> ⚠️ **Phase 3 的"代码智能"不是让 LLM 自由发挥写代码** +> +> 而是:LLM 串联工具 → 执行 → 如果报错 → 错误日志反馈给 LLM → 靶向修改代码/参数 → 重新执行 --- @@ -16,9 +67,11 @@ |------|------| | **统计工具** | 10 个高频工具(T检验、ANOVA、卡方、相关性等) | | **双模式支持** | 🆕 **智能分析模式**(上传数据→执行)+ **咨询模式**(无数据→SAP文档)| -| **核心流程** | 上传数据 → AI规划 → 用户确认 → R执行 → 结果交付 | +| **🆕 多工具流程规划** | LLM 规划 2-7 步串联流程(数据检查 → 前提检验 → 核心分析 → 效应量 → 结论)| +| **🆕 数据质量核查报告** | 自动生成"数据体检报告"(缺失值/异常值/分布/平衡性),含质量评分和建议 | +| **核心流程** | 上传数据 → **数据质量核查** → AI规划 → 用户确认 → **多步串联执行** → 结果交付 | | **交互能力** | 计划确认卡片、执行路径树、结果展示、代码下载、🆕 SAP文档导出 | -| **智能能力** | RAG工具检索、Planner规划、Critic结果解读 | +| **智能能力** | 🆕 **Prompt 动态注入** + RAG工具检索 + Planner规划 + Critic结果解读 | | **配置中台** | 🆕 统计决策表 + R代码库 + 参数映射 + 护栏规则链 + 解读模板 | | **数据安全** | LLM只看Schema,R服务处理真实数据 | diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/01-任务清单与进度追踪.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/01-任务清单与进度追踪.md index 19c7a365..32f6b924 100644 --- a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/01-任务清单与进度追踪.md +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/01-任务清单与进度追踪.md @@ -1,11 +1,13 @@ # SSA-Pro MVP 任务清单与进度追踪 -> **文档版本:** v1.7 +> **文档版本:** v2.0 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-20(V11 UI 前后端联调通过) +> **最后更新:** 2026-02-20(Phase 2A 前端集成完成 + Block-based 架构共识) > **更新频率:** 每日站会后更新 > -> **当前进度:** Phase 1 核心完成 ~95%,配置中台待开发 +> **当前进度:** Phase 2A 完成,下一步 Phase 2B Block-based 动态渲染重构 +> +> **📌 核心文档:** `07-Phase2A-智能化核心开发计划.md` | `08-Block-based动态结果渲染开发计划.md` --- @@ -111,7 +113,54 @@ ## Phase 2:智能规划与咨询模式(Week 3-4) -**里程碑目标:** 决策表驱动规划 + 咨询模式上线 +**里程碑目标:** 多工具流程规划 + 数据质量核查 + 咨询模式上线 + +### 🆕 核心智能化任务(优先级最高) + +| 状态 | 任务 | 预估 | 备注 | +|------|------|------|------| +| ✅ | 🆕 **Prompt 体系整合到后端开发指南** | 2h | **动态注入模式** | +| ✅ | 🆕 **多工具流程规划设计** | 3h | **WorkflowPlannerService 设计** | +| ✅ | 🆕 **数据质量核查报告设计** | 3h | **DataQualityService 设计** | +| ✅ | 🆕 **实现 WorkflowPlannerService** | 4h | **意图识别 + 变量类型判断 + 工具智能选择** | +| ✅ | 🆕 **实现 WorkflowExecutorService** | 4h | **串联执行 + SSE 实时进度 + 结果完整传递** | +| ✅ | 🆕 **实现 DataQualityService(Python)** | 3h | **CSV 直传 Python 解析,双端点支持** | +| ⬜ | 🆕 **实现 ST_QUALITY_REPORT R 脚本** | 4h | **缺失值/异常值/分布/平衡性** | +| ✅ | 🆕 **实现前端数据质量核查报告卡片** | 3h | **DataProfileCard 组件** | +| ✅ | 🆕 **实现前端多步骤流程展示** | 3h | **SSE 实时更新 + MVP 风格复用** | + +### 🆕 Phase 2A 前端集成任务(2026-02-20 完成) + +| 状态 | 任务 | 预估 | 备注 | +|------|------|------|------| +| ✅ | 🆕 **SSAChatPane 工作流调用集成** | 2h | **handleSend → generateWorkflowPlan** | +| ✅ | 🆕 **SSE 消息格式前后端对齐** | 2h | **camelCase/snake_case 兼容** | +| ✅ | 🆕 **多步骤执行日志 UI** | 2h | **MVP terminal-box + TraceLogItem 复用** | +| ✅ | 🆕 **多步骤结果展示 UI** | 3h | **统计量/分组表/回归系数/图表** | +| ✅ | 🆕 **DescriptiveResultView 组件** | 3h | **处理 variables+by_group 嵌套结构** | +| ✅ | 🆕 **多步骤 R 代码聚合导出** | 1h | **SSACodeModal 工作流模式** | +| ✅ | 🆕 **多步骤 Word 报告导出** | 3h | **exportWorkflowReport + 描述性统计** | +| ✅ | 🆕 **CSS 布局修复** | 2h | **position/padding/max-width 系统性修复** | +| ✅ | 🆕 **6 个前端 Bug 修复** | 3h | **SAP 误显示/SSE 卡死/结果丢失等** | + +### 🆕 Phase 2B Block-based 动态渲染任务(待开始) + +| 状态 | 任务 | 预估 | 备注 | +|------|------|------|------| +| ⬜ | 🆕 **创建 DynamicReport.tsx 组件** | 2h | **4 个 Block 渲染子组件** | +| ⬜ | 🆕 **创建 exportBlocksToWord.ts** | 2h | **Block 数组 → Word 文档** | +| ⬜ | 🆕 **后端透传 report_blocks** | 0.5h | **WorkflowExecutorService** | +| ⬜ | 🆕 **R 辅助函数库 block_helpers.R** | 1h | **make_table_block() 等** | +| ⬜ | 🆕 **SSAWorkspacePane 集成** | 1h | **优先读 report_blocks,fallback 旧逻辑** | +| ⬜ | 🆕 **descriptive.R 改造** | 1.5h | **输出 report_blocks** | +| ⬜ | 🆕 **t_test_ind.R 改造** | 1h | **输出 report_blocks** | +| ⬜ | 🆕 **logistic_binary.R 改造** | 1.5h | **输出 report_blocks** | +| ⬜ | 🆕 **chi_square.R 改造** | 1h | **输出 report_blocks** | +| ⬜ | 🆕 **correlation.R 改造** | 1h | **输出 report_blocks** | +| ⬜ | 🆕 **t_test_paired.R 改造** | 1h | **输出 report_blocks** | +| ⬜ | 🆕 **mann_whitney.R 改造** | 1h | **输出 report_blocks** | +| ⬜ | 🆕 **清理旧自定义渲染代码** | 2h | **删除 isDescriptive 等分支** | +| ⬜ | 🆕 **清理旧导出逻辑** | 1.5h | **删除 classifyExportVar 等** | ### R 服务任务 @@ -219,10 +268,15 @@ | Phase | 任务总数 | 已完成 | 进度 | |-------|---------|--------|------| | Phase 1 | 49 | 47 | 96% | -| Phase 2 | 30 | 0 | 0% | +| Phase 2 核心智能化 | 9 | 8 | 89% | +| Phase 2A 前端集成 | 9 | 9 | 100% | +| Phase 2B Block-based | 14 | 0 | 0% | +| Phase 2 其他(R/后端/前端) | 30 | 0 | 0% | | Phase 3 | 22 | 0 | 0% | -| **总计** | **101** | **47** | **47%** | +| **总计** | **133** | **64** | **48%** | +> **v2.0 更新**:Phase 2A 前端集成完成 + Block-based 架构共识达成(2026-02-20) +> **v1.8 更新**:纳入 Prompt 体系 + 多工具流程规划 + 数据质量核查报告设计(2026-02-20) > **v1.7 更新**:V11 UI 前后端联调通过,Phase 1 核心完成 96%(2026-02-20) > **v1.6 更新**:Phase 1 核心流程完成,T 检验端到端测试通过(2026-02-19) @@ -240,29 +294,33 @@ ### 2026-02-20 -**完成项:** +**上午 - V11 UI 联调:** - ✅ **V11 UI 像素级还原**:Gemini 风格全屏沉浸式体验 - ✅ **多任务支持**:单会话可执行多个分析任务,独立管理状态 - ✅ **单页滚动布局**:分析计划 → 执行日志 → 分析结果,步骤进度条导航 - ✅ **Word 报告导出**:使用 docx 库生成完整统计报告 -- ✅ **输入框遮挡修复**:Scroll Spacer 方案解决 Flexbox padding-bottom 问题 +- ✅ **输入框遮挡修复**:Scroll Spacer 方案 - ✅ **代码清理**:删除 7 个旧版 V8/V9 组件 -- ✅ **前后端联调测试**:端到端流程验证通过 -- ✅ **文档更新**:开发记录、模块状态、任务清单 + +**下午 - Phase 2A 前端集成(核心):** +- ✅ **Python 数据质量服务集成**:CSV 直传 Python 解析,修复端口/环境变量 +- ✅ **WorkflowPlannerService 实现**:正则变量提取 + 变量类型判断 + 智能工具选择 +- ✅ **WorkflowExecutorService 修复**:result 字段完整传递(plots/code/trace_log) +- ✅ **SSE 前后端对齐**:stream 路由触发执行 + 消息格式兼容 +- ✅ **多步骤 UI 复用 MVP 设计**:terminal-box 日志 + 统计量/表格/图表结果 +- ✅ **DescriptiveResultView 组件**:variables+by_group 嵌套结构解析 +- ✅ **多步骤导出功能**:R 代码聚合 + Word 报告(含描述性统计) +- ✅ **6 个 Bug 修复**:SAP 误显示、布局混乱、SSE 卡死、结果丢失、描述性统计、Word 导出 +- ✅ **Block-based 架构共识**:评估并认可动态结果渲染协议规范 +- ✅ **Block-based 开发计划**:`08-Block-based动态结果渲染开发计划.md` **关键技术方案:** -- Scroll Spacer:物理占位元素解决 Flexbox 滚动问题 -- AnalysisRecord:多任务状态管理架构 -- docx 库:Word 文档生成(支持表格、图片嵌入) +- Block-based Protocol:4 种 Block 类型(markdown/table/image/key_value) +- 渐进式迁移:report_blocks 优先,fallback 旧逻辑 +- SSE 触发模式:客户端连接时异步触发 executeWorkflow -**待解决:** -- 配置中台功能待开发(DecisionTableLoader, RCodeLibraryService 等) -- json-repair 和 zod 依赖待安装 -- DataParserService 隐私保护待实现 - -**下一步建议:** -- 方向 A:完成 Phase 1 配置中台(推荐,使 MVP 功能完整) -- 方向 B:进入 Phase 2 扩展更多统计方法 +**下一步:** +- Phase 2B:Block-based 动态渲染重构(~2.5 天) --- diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/02-R服务开发指南.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/02-R服务开发指南.md index 734038da..7cc555e2 100644 --- a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/02-R服务开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/02-R服务开发指南.md @@ -1,8 +1,8 @@ # SSA-Pro R 服务开发指南 -> **文档版本:** v1.5 +> **文档版本:** v1.6 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-18(纳入专家配置体系 + 统一入口函数) +> **最后更新:** 2026-02-20(纳入智能化演进共识 + 错误捕获管道设计) > **目标读者:** R 统计工程师 --- @@ -237,6 +237,50 @@ function(req, tool_code) { ## 4. 错误码定义 +### 🆕 4.0 错误捕获管道设计(为 Phase 3 靶向修改铺路) + +> **重要**:结构化的错误捕获是 Phase 3 "靶向代码修改"能力的基础。 +> +> 详细背景参考:`04-开发计划/06-智能化演进共识与MVP执行计划.md` + +**Phase 3 的工作流程:** +``` +工具执行报错 → 错误捕获管道 → 结构化 JSON → 反馈给 LLM → LLM 靶向修改代码 +``` + +**错误 JSON 结构要求(便于 LLM 理解):** +```json +{ + "status": "error", + "error_code": "E002", + "error_type": "business", + "message": "列 'blood_pressure' 类型应为 numeric,实际为 character", + "user_hint": "该列包含非数值数据,请检查数据格式", + "context": { + "tool_code": "ST_T_TEST_IND", + "problematic_column": "blood_pressure", + "expected_type": "numeric", + "actual_type": "character", + "sample_values": ["120", "130", "未知", "125"], + "line_number": 45 + } +} +``` + +**关键字段说明:** + +| 字段 | MVP 用途 | Phase 3 用途 | +|------|---------|-------------| +| `error_code` | 日志分类 | LLM 识别错误类型 | +| `error_type` | 区分业务/系统错误 | LLM 判断是否可自愈 | +| `message` | 开发调试 | LLM 理解错误原因 | +| `user_hint` | 前端展示 | 保留 | +| `context` | 🆕 可选 | LLM 靶向修改的关键信息 | + +> **MVP 阶段行动**:错误响应中尽量包含 `context` 信息,为 Phase 3 积累"黄金数据集"。 + +--- + ```r # utils/error_codes.R # 📌 结构化错误码,便于 LLM 自愈 diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/03-后端开发指南.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/03-后端开发指南.md index 477e5515..d749289d 100644 --- a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/03-后端开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/03-后端开发指南.md @@ -1,8 +1,8 @@ # SSA-Pro 后端开发指南 -> **文档版本:** v1.5 +> **文档版本:** v1.7 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-18(纳入专家配置体系 + 决策表匹配 + R代码库) +> **最后更新:** 2026-02-20(纳入 Prompt 体系 + 多工具流程规划 + 数据质量核查报告) > **目标读者:** Node.js 后端工程师 --- @@ -61,6 +61,517 @@ backend/src/modules/ssa/ --- +## 1.2 🆕 Prompt 体系与专家配置边界 + +> **核心理念:骨架与血肉的分离** +> +> 详细设计参考:`06-开发记录/SSA-Pro Prompt体系与专家配置边界梳理.md` + +### 1.2.1 动态 Prompt 注入模式 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Prompt 动态注入架构 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ AI 工程师维护 统计专家维护 │ +│ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Prompt 模板 │ │ 配置中台 (Excel) │ │ +│ │ (骨架) │ │ - 决策表 │ │ +│ │ │ │ - 使用规则 │ │ +│ │ {{占位符}} │ ◀─────── │ - 解读模板 │ │ +│ │ │ 注入 │ - 禁用词列表 │ │ +│ └─────────────┘ └─────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 完整 Prompt = 骨架 + 血肉 → 发送给 LLM ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.2.2 三个核心 Prompt + +| 环节 | Prompt 名称 | 作用 | 专家注入内容 | +|------|------------|------|-------------| +| **意图重写** | `SSA_QUERY_REWRITER` | 将医生口语翻译为统计术语 | 同义词字典 | +| **智能规划** | `SSA_PLANNER` | 选择工具 + 生成参数映射 | 工具定义 + 使用规则 | +| **结果解读** | `SSA_CRITIC` | 生成论文级结论 | 解读模板 + 禁用词 | + +### 1.2.3 职责边界表 + +| 资产类型 | 谁来写? | 存在哪里? | 被谁执行? | +|---------|---------|-----------|-----------| +| System Prompt 模板 (骨架) | AI 工程师 | `prompt_templates` 表 | 传给 LLM | +| 工具适用条件/数据要求 | 统计专家 | 配置中台 Excel | 注入 Prompt → LLM | +| 统计护栏规则 | 统计专家 | 配置中台 Excel | **传给 R 服务,由 R 强执行** | +| R 代码模板 | 统计专家 | 配置中台 Excel | 传给 R 服务 | +| 论文结论解释规范 | 统计专家 | 配置中台 Excel | 注入 Critic Prompt → LLM | + +> ⚠️ **关键原则**:统计护栏规则(如正态性检验 P<0.05 降级)**绝对不要**放到 Prompt 里让 LLM 判断。这些规则必须由 R 代码强逻辑执行。 + +--- + +## 1.3 🆕 多工具流程规划设计 + +> **愿景目标**:"不是执行方法,而是规划流程" +> +> **MVP 目标**:LLM 能够规划 2-7 个工具的串联执行流程 + +### 1.3.1 流程规划架构 + +``` +用户:"比较两组患者的血压差异" + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 多工具流程规划 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: 意图解析 │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 目的:差异比较 | 变量:连续 | 分组:二分类 | 设计:独立 ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ Step 2: 流程规划(LLM 输出) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ { ││ +│ │ "workflow": [ ││ +│ │ { "step": 1, "tool": "ST_DATA_CHECK", "name": "数据校验" },││ +│ │ { "step": 2, "tool": "ST_QUALITY_REPORT", "name": "质量核查" },││ +│ │ { "step": 3, "tool": "ST_NORMALITY_TEST", "name": "正态性检验" },││ +│ │ { "step": 4, "tool": "ST_LEVENE_TEST", "name": "方差齐性" },││ +│ │ { "step": 5, "tool": "ST_T_TEST_IND", "name": "T检验" },││ +│ │ { "step": 6, "tool": "ST_EFFECT_SIZE", "name": "效应量" },││ +│ │ { "step": 7, "tool": "ST_CONCLUSION", "name": "结论生成" }││ +│ │ ], ││ +│ │ "reasoning": "两组独立样本比较,需先检验前提条件..." ││ +│ │ } ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ Step 3: 串联执行 │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 工具1 → 工具2 → 工具3 → ... → 工具N ││ +│ │ ↓ ↓ ↓ ↓ ││ +│ │ 结果1 → 结果2 → 结果3 → ... → 最终报告 ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.3.2 WorkflowPlannerService 设计 + +```typescript +// planner/WorkflowPlannerService.ts + +interface WorkflowStep { + step: number; + toolCode: string; + toolName: string; + params?: Record; + dependsOn?: number[]; // 依赖的前置步骤 +} + +interface WorkflowPlan { + workflow: WorkflowStep[]; + reasoning: string; + estimatedTime: number; // 预估耗时(秒) +} + +export class WorkflowPlannerService { + + /** + * 生成多工具执行流程 + */ + async generateWorkflow( + sessionId: string, + userQuery: string, + dataSchema: object + ): Promise { + + // 1. 获取候选工具 + const tools = await this.toolRetrieval.retrieveTools(userQuery, dataSchema, 10); + + // 2. 构造 Prompt(包含流程规划指令) + const prompt = this.buildWorkflowPrompt(userQuery, dataSchema, tools); + + // 3. 调用 LLM 生成流程 + const llm = LLMFactory.getAdapter('deepseek-v3'); + const response = await llm.chat([ + { role: 'system', content: prompt }, + { role: 'user', content: userQuery } + ]); + + // 4. 解析 + 校验 + return this.parseWorkflowPlan(response, tools); + } + + /** + * 构造流程规划 Prompt + */ + private buildWorkflowPrompt(query: string, schema: object, tools: any[]): string { + return ` +你是一位顶尖的临床数据科学家。你拥有一个包含 ${tools.length} 个专家级统计工具的代码库。 + +用户数据结构: +${JSON.stringify(schema, null, 2)} + +可用工具库: +${tools.map(t => `- ${t.toolCode}: ${t.name} - ${t.description}`).join('\n')} + +请根据用户需求,规划一个完整的统计分析流程。流程应包含: +1. 数据质量核查(必须) +2. 前提条件检验(如适用) +3. 核心统计分析 +4. 效应量计算(如适用) +5. 结论生成 + +输出 JSON 格式: +{ + "workflow": [ + { "step": 1, "toolCode": "工具代码", "toolName": "工具名称" }, + ... + ], + "reasoning": "规划理由" +} + +只输出 JSON,不要其他内容。 + `.trim(); + } +} +``` + +### 1.3.3 WorkflowExecutorService 设计 + +```typescript +// executor/WorkflowExecutorService.ts + +interface StepResult { + step: number; + toolCode: string; + status: 'success' | 'warning' | 'error'; + result?: any; + error?: string; + executionMs: number; +} + +export class WorkflowExecutorService { + + /** + * 串联执行多个工具 + */ + async executeWorkflow( + sessionId: string, + workflow: WorkflowStep[], + onStepComplete?: (stepResult: StepResult) => void // 实时回调 + ): Promise { + + const results: StepResult[] = []; + let previousResult: any = null; + + for (const step of workflow) { + const startTime = Date.now(); + + try { + // 构造本步骤的输入(可能依赖前置步骤的输出) + const input = this.buildStepInput(step, previousResult, sessionId); + + // 调用 R 服务执行 + const result = await this.rClient.execute(sessionId, { + tool_code: step.toolCode, + params: input + }); + + const stepResult: StepResult = { + step: step.step, + toolCode: step.toolCode, + status: result.status === 'success' ? 'success' : 'warning', + result: result, + executionMs: Date.now() - startTime + }; + + results.push(stepResult); + previousResult = result; + + // 实时通知前端 + onStepComplete?.(stepResult); + + } catch (error: any) { + const stepResult: StepResult = { + step: step.step, + toolCode: step.toolCode, + status: 'error', + error: error.message, + executionMs: Date.now() - startTime + }; + + results.push(stepResult); + onStepComplete?.(stepResult); + + // 决定是否继续执行后续步骤 + if (this.isCriticalError(error)) { + break; // 关键错误,中断流程 + } + // 非关键错误,继续执行 + } + } + + return results; + } +} +``` + +--- + +## 1.4 🆕 数据质量核查报告设计 + +> **愿景目标**:自动生成"数据体检报告",主动告诉用户数据有什么问题 +> +> **MVP 目标**:在执行分析前,先生成数据质量核查报告 + +### 1.4.1 核查报告结构 + +```typescript +// types/DataQualityReport.ts + +interface DataQualityReport { + // 基础统计 + summary: { + totalRows: number; + totalColumns: number; + numericColumns: number; + categoricalColumns: number; + }; + + // 缺失值分析 + missingAnalysis: { + totalMissing: number; + missingRate: number; // 总体缺失率 + columns: Array<{ + name: string; + missingCount: number; + missingRate: number; + suggestion: string; // 处理建议 + }>; + }; + + // 异常值检测 + outlierAnalysis: { + columns: Array<{ + name: string; + outlierCount: number; + outlierValues: any[]; + method: 'IQR' | 'ZScore'; + suggestion: string; + }>; + }; + + // 分布检验(数值变量) + distributionAnalysis: { + columns: Array<{ + name: string; + shapiroP: number; + isNormal: boolean; + skewness: number; + kurtosis: number; + suggestion: string; + }>; + }; + + // 分组平衡性(如有分组变量) + groupBalance?: { + groupColumn: string; + groups: Array<{ + name: string; + count: number; + percentage: number; + }>; + isBalanced: boolean; + suggestion: string; + }; + + // 整体评估 + overallAssessment: { + qualityScore: number; // 0-100 + level: 'good' | 'acceptable' | 'poor'; + warnings: string[]; + recommendations: string[]; + }; +} +``` + +### 1.4.2 DataQualityService 设计 + +```typescript +// planner/DataQualityService.ts + +export class DataQualityService { + + /** + * 生成数据质量核查报告 + * 这是 R 服务调用,不是 LLM + */ + async generateReport(sessionId: string): Promise { + + // 调用 R 服务的数据质量核查工具 + const result = await this.rClient.execute(sessionId, { + tool_code: 'ST_QUALITY_REPORT', + params: { + check_missing: true, + check_outliers: true, + check_distribution: true, + check_balance: true + } + }); + + return this.transformToReport(result); + } + + /** + * 生成用户友好的摘要(可选用 LLM 增强) + */ + async generateSummary(report: DataQualityReport): Promise { + const llm = LLMFactory.getAdapter('deepseek-v3'); + + const prompt = ` +你是一位数据分析专家。请根据以下数据质量核查结果,生成一段简洁的中文摘要(3-5句话), +告诉用户数据的整体质量如何,主要问题是什么,是否可以继续分析。 + +核查结果: +${JSON.stringify(report, null, 2)} + +请直接输出摘要文本。 + `; + + return await llm.chat([{ role: 'user', content: prompt }]); + } +} +``` + +### 1.4.3 R 服务端实现(ST_QUALITY_REPORT) + +```r +# tools/quality_report.R + +#' @tool_code ST_QUALITY_REPORT +#' @name 数据质量核查报告 +#' @description 生成全面的数据质量评估报告 + +run_analysis <- function(input) { + # 加载数据 + df <- load_data(input) + + report <- list() + + # 1. 基础统计 + report$summary <- list( + totalRows = nrow(df), + totalColumns = ncol(df), + numericColumns = sum(sapply(df, is.numeric)), + categoricalColumns = sum(sapply(df, is.character) | sapply(df, is.factor)) + ) + + # 2. 缺失值分析 + report$missingAnalysis <- analyze_missing(df) + + # 3. 异常值检测(IQR 方法) + report$outlierAnalysis <- analyze_outliers(df) + + # 4. 分布检验 + report$distributionAnalysis <- analyze_distribution(df) + + # 5. 分组平衡性 + if (!is.null(input$group_var)) { + report$groupBalance <- analyze_balance(df, input$group_var) + } + + # 6. 整体评估 + report$overallAssessment <- calculate_quality_score(report) + + return(list( + status = "success", + report = report + )) +} + +# 计算整体质量评分 +calculate_quality_score <- function(report) { + score <- 100 + warnings <- c() + recommendations <- c() + + # 缺失值扣分 + if (report$missingAnalysis$missingRate > 0.1) { + score <- score - 20 + warnings <- c(warnings, "缺失值比例超过10%") + recommendations <- c(recommendations, "建议处理缺失值后再进行分析") + } else if (report$missingAnalysis$missingRate > 0.05) { + score <- score - 10 + warnings <- c(warnings, "存在一定比例的缺失值") + } + + # 异常值扣分 + outlier_cols <- sum(sapply(report$outlierAnalysis$columns, function(x) x$outlierCount > 0)) + if (outlier_cols > 0) { + score <- score - 5 * outlier_cols + warnings <- c(warnings, paste0(outlier_cols, "个变量存在异常值")) + } + + # 非正态扣分(提示,不强制扣分) + non_normal <- sum(!sapply(report$distributionAnalysis$columns, function(x) x$isNormal)) + if (non_normal > 0) { + recommendations <- c(recommendations, + paste0(non_normal, "个变量不满足正态分布,系统将自动选择非参数方法")) + } + + # 确定等级 + level <- if (score >= 80) "good" else if (score >= 60) "acceptable" else "poor" + + return(list( + qualityScore = max(0, score), + level = level, + warnings = warnings, + recommendations = recommendations + )) +} +``` + +### 1.4.4 前端展示(核查报告卡片) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 📊 数据质量核查报告 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 📈 数据概况 │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 总样本量:200 行 × 15 列 ││ +│ │ 数值变量:8 个 | 分类变量:7 个 ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ ⚠️ 发现的问题 │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ • 缺失值:血压字段有 12 例缺失 (6%) ││ +│ │ • 异常值:2 例血压 > 300 mmHg(疑似记录错误) ││ +│ │ • 正态性:治疗组血压不满足正态分布 ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ 💡 系统建议 │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 1. 建议处理 2 例异常值后再分析 ││ +│ │ 2. 由于正态性不满足,系统将自动选择非参数方法 ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ 🎯 整体评估:良好 (82/100) │ +│ │ +│ [继续分析] [下载报告] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + ## 2. 数据库 Schema(Prisma) ```prisma @@ -683,6 +1194,45 @@ export class ToolRetrievalService { ### 4.3 PlannerService(AI 规划 + JSON 容错) +#### 🆕 Prompt 世界观设计(智能化演进关键) + +> **重要**:Prompt 的"世界观"设计直接影响 LLM 的推理质量和未来演进能力。 +> +> 详细背景参考:`04-开发计划/06-智能化演进共识与MVP执行计划.md` + +**错误的世界观(接线员思维):** +``` +你是一个工具选择器,从以下列表中选择合适的工具... +``` + +**正确的世界观(数据科学家思维):** +``` +你是一位顶尖的临床数据科学家,拥有以下能力: + +1. 深刻理解医学研究的统计学需求 +2. 精通各类统计方法的适用场景和前提条件 +3. 能够诊断数据特征并选择最优分析路径 + +你现在拥有一个包含 100+ 专家级统计算法的代码库。 +每个算法都经过统计学专家的严格验证,确保结果的权威性。 + +请理解医生的研究意图,诊断数据特征,从代码库中选择最合适的工具组合, +并制定完整的分析计划。你的目标是帮助医生产出可以直接用于 SCI 论文的统计结果。 +``` + +**为什么这很重要?** + +| 维度 | 接线员思维 | 数据科学家思维 | +|------|-----------|---------------| +| LLM 自我认知 | 被动的工具选择器 | 主动的分析规划师 | +| 推理深度 | 简单匹配 | 深度分析数据特征 | +| 输出质量 | 可能选错工具 | 更准确的工具选择 | +| Phase 3 演进 | 难以扩展到代码修改 | 自然过渡到代码理解和修改 | + +> **MVP 阶段行动**:更新 `SSA_PLANNER` Prompt 模板,使用"数据科学家"世界观。 + +--- + ```typescript // services/PlannerService.ts import { LLMFactory } from '@/common/llm/adapters/LLMFactory'; diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/04-前端开发指南.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/04-前端开发指南.md index a2741be0..291df6b9 100644 --- a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/04-前端开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/04-前端开发指南.md @@ -1,10 +1,10 @@ # SSA-Pro 前端开发指南 -> **文档版本:** v1.5 +> **文档版本:** v1.6 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-18(纳入专家配置体系 + 护栏 Action 展示) +> **最后更新:** 2026-02-20(纳入智能化演进共识 + 分步展示设计) > **目标读者:** 前端工程师 -> **原型参考:** `03-UI设计/智能统计分析V2.html` +> **原型参考:** `03-UI设计/V11.html`(V11 像素级还原) --- @@ -62,6 +62,47 @@ frontend-v2/src/modules/ssa/ | **无数据友好** | 咨询模式不要求上传数据 | | **SAP 导出** | 咨询完成后可下载 Word/Markdown | +### 1.2 🆕 智能化演进设计(为 Phase 3 铺路) + +> **重要**:前端 UI 设计需要为未来的"靶向代码修改"能力预留扩展点。 +> +> 详细背景参考:`04-开发计划/06-智能化演进共识与MVP执行计划.md` + +**Phase 3 的工作流程(前端视角):** +``` +用户提问 → 系统规划多个步骤 → 依次执行 + ↓ + 步骤 N 执行报错 + ↓ + 前端展示错误信息 + "正在自动修复..." + ↓ + LLM 靶向修改代码 → 重新执行 + ↓ + 成功 → 继续下一步 +``` + +**MVP 阶段需要预埋的 UI 能力:** + +| 能力 | MVP 用途 | Phase 3 用途 | +|------|---------|-------------| +| **分步展示** | 显示工具执行链 | 显示每步的执行/修复状态 | +| **步骤状态** | 成功/失败/进行中 | 增加"修复中"状态 | +| **错误详情** | 展示用户友好错误 | 展示"正在分析错误原因..." | +| **实时日志** | 执行轨迹 | 显示 LLM 修复过程 | + +**SAP 卡片的步骤展示(已在 V11 实现):** +``` +系统将按以下步骤为您完成分析: + +✅ 步骤 1:数据校验 (ST_DATA_CHECK) +✅ 步骤 2:缺失值检测 (ST_MISSING_REPORT) +🔄 步骤 3:正态性检验 (ST_NORMALITY_TEST) ← 执行中 +⏳ 步骤 4:独立样本T检验 (ST_T_TEST_IND) +⏳ 步骤 5:结论生成 (ST_CONCLUSION) +``` + +> **MVP 阶段行动**:确保 `ExecutionTrace` 组件支持多步骤展示和状态切换。 + --- ## 2. 原型图核心元素解析 diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/06-智能化演进共识与MVP执行计划.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/06-智能化演进共识与MVP执行计划.md new file mode 100644 index 00000000..90bab9f9 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/06-智能化演进共识与MVP执行计划.md @@ -0,0 +1,426 @@ +# SSA-Pro 智能化演进共识与 MVP 执行计划 + +**文档版本:** v1.1 +**创建日期:** 2026-02-20 +**最后更新:** 2026-02-20(澄清 Phase 3 靶向修改概念) +**文档性质:** 团队共识文档 & 执行计划 +**参与讨论:** 产品团队、架构团队、开发团队 + +--- + +## 一、背景与讨论历程 + +### 1.1 讨论起因 + +在 SSA-Pro 智能统计分析模块的开发过程中,团队就"智能化"的实现路径产生了深入讨论: + +- **愿景设计**:《SSA-Pro 理想状态与智能化愿景设计》提出了"代码智能"范式 +- **架构审查**:开发团队对愿景进行了多轮审查,提出了安全性和工程可行性的关切 +- **最终共识**:经过充分讨论,团队在愿景与落地之间找到了平衡点 + +### 1.2 关键讨论文档 + +| 文档 | 核心观点 | +|------|---------| +| 《SSA-Pro 理想状态与智能化愿景设计》 | 代码智能范式,LLM 动态修改代码 | +| 《架构审查反馈与智能化路径讨论》 | 区分工具调用 vs 代码智能,明确产品定位 | +| 《终极架构共识与智能化演进备忘录》 | 提出"受限代码生成"桥梁方案 | +| 《智能化演进路径评估报告》 | Tool Calling vs Agentic Code Gen 对比 | +| 《智能化演进阶梯》 | Phase 2 是 Phase 3 的基础 | + +--- + +## 二、最终共识 + +### 2.1 产品定位共识 + +> **SSA-Pro 的终极目标是 Level 3:颠覆性的智能分析助手** +> +> 让不懂统计的医生,能够完成专业级的统计分析。 + +### 2.2 技术路径共识 + +| 共识点 | 说明 | +|--------|------| +| **代码智能是正确的终点** | 100个R脚本作为基础组件,LLM 编排调用,报错时靶向修改 | +| **Tool Calling 是必经的起点** | MVP 阶段必须先把工具调用做到极致 | +| **分阶段实现是务实选择** | 爬行 → 行走 → 奔跑 | +| **医疗领域零容错** | P值必须100%正确,安全红线不可逾越 | + +### 2.3 关键澄清:Phase 3 的"靶向修改" + +> ⚠️ **重要澄清:Phase 3 不是让 LLM 自由发挥写代码,而是"靶向修改"** + +**错误理解 ❌** + +| 误解 | 说明 | +|------|------| +| 100个R文件是"学习资料" | LLM 从中学习后自由生成代码 | +| LLM 自由发挥写代码 | 从零开始生成统计代码 | +| 漫无目的地改代码 | 没有明确目标的代码修改 | + +**正确理解 ✅** + +| 正解 | 说明 | +|------|------| +| 100个R文件是"基础组件" | 经过专家验证的固定工具 | +| LLM 主要工作是"编排" | 串联多个工具形成分析流程 | +| 只在报错时"靶向修改" | 根据错误信息针对性调整代码/参数 | +| 修改范围有限 | 修复错误,而非重写工具 | + +**Phase 3 执行流程图:** + +``` +用户提问 + │ + ▼ +LLM 串联 N 个工具(基于 100 个固定工具) + │ + ▼ +依次执行每个工具 + │ + ├── 成功 → 继续执行下一个工具 → 全部完成 → 输出结果 + │ + └── 报错 → 捕获【错误日志 + 用户数据片段】 + │ + ▼ + 反馈给 LLM 分析错误原因 + │ + ▼ + LLM 针对性修改该工具的代码/参数 + (不是重写,是修复) + │ + ▼ + 重新执行该工具 → 成功则继续 +``` + +**本质区别:** + +| 维度 | 自由生成(错误理解) | 靶向修改(正确理解) | +|------|---------------------|---------------------| +| **触发条件** | 每次都生成新代码 | 只在报错时修改 | +| **修改范围** | 整个代码 | 出错的部分 | +| **代码来源** | LLM 凭空生成 | 基于现有工具代码 | +| **目标** | 生成新功能 | 修复运行错误 | + +### 2.4 关键认知修正 + +| 原有观点 | 修正后观点 | +|---------|-----------| +| MVP 就应该做代码智能 | MVP 必须先把 Tool Calling 打透 | +| 100个R代码让 LLM 自由修改 | 核心统计代码必须锁死,只有数据清洗可放开 | +| 宏工具方案与目标不兼容 | 宏工具是通往代码智能的必经之路 | + +### 2.5 安全红线 + +| 风险 | 说明 | 应对措施 | +|------|------|---------| +| **RCE 漏洞** | 动态执行 LLM 生成的代码存在远程代码执行风险 | Phase 3 前必须有 MicroVM 级别隔离 | +| **统计学幻觉** | LLM 可能生成"看似正确实则谬误"的统计代码 | 核心统计代码必须使用专家验证的固定脚本 | +| **不可复现** | 动态生成的代码每次可能不同 | MVP 阶段使用固定工具保证 100% 可复现 | + +--- + +## 三、三阶段演进路线图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SSA-Pro 智能化演进路线图 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Phase 1/2: 爬行期 (MVP) ───────────────────────────────────────────── │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 目标:打透 Tool Calling,建立用户信任 │ │ +│ │ 机制:100个工具作为固定API,LLM 做智能调度 │ │ +│ │ AI角色:高级调度员 / 全科主任医师 │ │ +│ │ 交付:V11 UI + 工具编排 + 论文级输出 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 2.5: 行走期 ─────────────────────────────────────────────────── │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 目标:引入受限的代码智能 │ │ +│ │ 机制: │ │ +│ │ - 数据清洗:允许 LLM 生成 dplyr 代码(白名单 AST 检查) │ │ +│ │ - 核心统计:仍然强制使用固定工具 │ │ +│ │ AI角色:数据清洗实习生 + 统计调度员 │ │ +│ │ 交付:自愈型数据处理 + 错误反馈机制 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 3: 奔跑期 (终极愿景) ────────────────────────────────────────── │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 目标:自愈型靶向代码修改 │ │ +│ │ 前提:MicroVM 沙箱、AST 安全检查、充分的黄金数据集 │ │ +│ │ 机制: │ │ +│ │ - LLM 串联多个工具执行 │ │ +│ │ - 运行成功 → 直接返回结果 │ │ +│ │ - 运行报错 → 错误日志+数据反馈给 LLM → 靶向修改代码/参数 │ │ +│ │ - 重新执行 → 直到成功 │ │ +│ │ AI角色:全能数据科学家 / AI 统计学家 │ │ +│ │ 关键:不是自由生成代码,而是基于现有工具的靶向修复 │ │ +│ │ 交付:颠覆性的智能分析体验 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、MVP 阶段执行计划 + +### 4.1 核心目标 + +> **把 Tool Calling 做到极致,让 LLM 成为最聪明的"全科主任医师"** + +- 准确理解医生意图 +- 精准选择工具组合 +- 正确编排执行顺序 +- 输出论文级结果 + +### 4.2 具体任务清单 + +#### 任务 1:Planner Prompt 重构 🔴 最高优先级 + +**目标:** 改变 LLM 的"世界观",从"接线员"升级为"数据科学家" + +**当前 Prompt(接线员思维):** +``` +你是一个工具选择器,从以下列表中选择合适的工具... +``` + +**目标 Prompt(数据科学家思维):** +``` +你是一位顶尖的临床数据科学家,拥有以下能力: + +1. 深刻理解医学研究的统计学需求 +2. 精通各类统计方法的适用场景和前提条件 +3. 能够诊断数据特征并选择最优分析路径 + +你现在拥有一个包含 100+ 专家级统计算法的代码库。 +每个算法都经过统计学专家的严格验证,确保结果的权威性。 + +请理解医生的研究意图,诊断数据特征,从代码库中选择最合适的工具组合, +并制定完整的分析计划。你的目标是帮助医生产出可以直接用于 SCI 论文的统计结果。 +``` + +**工时估计:** 0.5 天 + +--- + +#### 任务 2:向量库增强(为 Phase 3 埋伏笔) + +**目标:** 在 `tools_library.xlsx` 中增加字段,为未来的代码智能铺路 + +| 新增字段 | 用途 | 示例 | +|---------|------|------| +| `core_code_snippet` | R脚本的核心代码片段 | `t.test(x, y, paired = FALSE)` | +| `typical_error_patterns` | 常见报错及处理方式 | `"non-numeric argument" -> 检查数据类型` | +| `data_requirements` | 数据格式要求 | `需要两列数值型数据` | +| `statistical_assumptions` | 统计前提条件 | `正态分布、方差齐性` | + +**工时估计:** 1 天 + +--- + +#### 任务 3:执行沙箱强化 + +**目标:** 建立健壮的错误捕获和日志系统 + +| 子任务 | 说明 | +|--------|------| +| 错误捕获管道 | R stderr → 结构化 JSON → 可被 LLM 理解 | +| 超时机制 | 单次执行超过 30s 自动终止 | +| 资源限制 | CPU/内存使用上限 | +| 日志记录 | 完整记录每次调用,积累黄金数据集 | + +**工时估计:** 2 天 + +--- + +#### 任务 4:工具编排能力 + +**目标:** 实现多工具串联执行 + +``` +用户:"帮我做一个完整的组间比较分析" + +LLM 生成的执行计划: +───────────────────────────────────── +步骤 1: ST_DATA_CHECK → 数据校验 +步骤 2: ST_MISSING_REPORT → 缺失值检测 +步骤 3: ST_NORMALITY_TEST → 正态性检验 +步骤 4: ST_LEVENE_TEST → 方差齐性检验 +步骤 5: ST_T_TEST_IND → 独立样本T检验(或根据前提选择非参数) +步骤 6: ST_EFFECT_SIZE → 效应量计算 +步骤 7: ST_CONCLUSION → 结论生成 +``` + +**工时估计:** 3 天 + +--- + +#### 任务 5:前端 SAP 卡片增强 + +**目标:** 展示完整的工具调用链,增加用户信任 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 📋 统计分析计划 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 研究目的:比较两组患者的疗效差异 │ +│ 数据特征:连续变量,两组比较,样本量 n=120 │ +│ │ +│ 系统将按以下步骤为您完成分析: │ +│ │ +│ ✅ 步骤 1:数据校验 (ST_DATA_CHECK) │ +│ ✅ 步骤 2:缺失值检测 (ST_MISSING_REPORT) │ +│ ✅ 步骤 3:正态性检验 (ST_NORMALITY_TEST) │ +│ ✅ 步骤 4:方差齐性检验 (ST_LEVENE_TEST) │ +│ ✅ 步骤 5:独立样本T检验 (ST_T_TEST_IND) │ +│ ✅ 步骤 6:效应量计算 (ST_EFFECT_SIZE) │ +│ ✅ 步骤 7:结论生成 (ST_CONCLUSION) │ +│ │ +│ 预计耗时:约 30 秒 │ +│ │ +│ [确认执行] [修改计划] │ +└─────────────────────────────────────────────────────────────┘ +``` + +**工时估计:** 1 天 + +--- + +#### 任务 6:Critic Agent 论文级输出 + +**目标:** 升级结论生成,符合医学期刊规范 + +**输出模板:** +``` +## 统计方法 +采用独立样本 t 检验比较两组患者的 [指标名称] 差异。 +统计分析使用 R 4.3.0 完成,P < 0.05 认为差异有统计学意义。 + +## 结果 +[实验组/对照组] 的 [指标名称] 为 [均值 ± 标准差], +两组间差异 [有/无] 统计学意义 (t = [值], P = [值])。 + +## 效应量 +Cohen's d = [值],表明效应量为 [小/中/大]。 +``` + +**工时估计:** 1 天 + +--- + +### 4.3 任务优先级排序 + +| 优先级 | 任务 | 工时 | 依赖 | +|--------|------|------|------| +| 🔴 P0 | Planner Prompt 重构 | 0.5 天 | 无 | +| 🔴 P0 | 执行沙箱强化 | 2 天 | 无 | +| 🟡 P1 | 工具编排能力 | 3 天 | 沙箱强化 | +| 🟡 P1 | 向量库增强 | 1 天 | 无 | +| 🟢 P2 | 前端 SAP 卡片增强 | 1 天 | 工具编排 | +| 🟢 P2 | Critic Agent 论文级输出 | 1 天 | 无 | + +**总工时估计:** 8.5 天 + +--- + +## 五、为 Phase 3 埋下的伏笔 + +虽然 MVP 阶段使用 Tool Calling 范式,但我们要为未来的"靶向修改"能力做好准备: + +| 伏笔 | Phase 3 用途 | MVP 阶段行动 | +|------|-------------|-------------| +| **Prompt 世界观** | LLM 理解自己可以修改代码 | 在 Prompt 中强调代码库概念 | +| **向量库代码片段** | LLM 理解工具代码,便于靶向修改 | 存入核心代码片段 | +| **黄金数据集** | 学习"什么错误如何修复" | 完整记录每次调用(含报错) | +| **错误捕获管道** | 将错误信息结构化反馈给 LLM | 建立结构化错误日志 | +| **前端分步展示** | 展示修复过程,支持人机协同 | UI 已支持步骤级展示 | + +### 5.1 错误捕获管道的重要性 + +Phase 3 的核心能力是"遇错自愈",这依赖于高质量的错误反馈: + +``` +工具执行报错 + │ + ▼ +错误捕获管道(MVP 阶段建设) + │ + ├── 捕获 R stderr 原始错误 + │ + ├── 解析为结构化 JSON + │ { + │ "error_type": "non-numeric argument", + │ "line_number": 45, + │ "problematic_column": "blood_pressure", + │ "sample_data": ["120", "130", "未知", "125"] + │ } + │ + └── 反馈给 LLM 进行靶向修复 +``` + +这个管道在 MVP 阶段就要建设完成,为 Phase 3 打好基础。 + +--- + +## 六、成功标准 + +### MVP 阶段验收标准 + +| 指标 | 目标 | +|------|------| +| 工具选择准确率 | ≥ 95% | +| 参数填充正确率 | ≥ 98% | +| 执行成功率 | ≥ 90%(标准数据集) | +| 结果可复现性 | 100% | +| 用户满意度 | ≥ 4.0 / 5.0 | + +### Phase 2.5 进入条件 + +| 条件 | 说明 | +|------|------| +| MVP 稳定运行 3 个月 | 验证基础架构稳定性 | +| 积累 1000+ 成功调用记录 | 黄金数据集基础 | +| 错误捕获管道完善 | 支持结构化错误反馈 | +| AST 白名单检查机制就绪 | 数据清洗代码安全保障 | + +--- + +## 七、总结 + +### 一句话总结 + +> **代码智能是正确的终点,但 Tool Calling 是必经的起点。** +> +> **先把 100 个工具的调度做到极致,积累黄金数据集,Phase 3 自然水到渠成。** + +### 团队共识 + +1. **愿景一致**:代码智能是终极目标 +2. **路径清晰**:爬行 → 行走 → 奔跑 +3. **红线明确**:医疗领域零容错,P值必须100%正确 +4. **执行务实**:MVP 聚焦 Tool Calling,为未来埋伏笔 +5. **概念清晰**:Phase 3 是"靶向修改",不是"自由生成" + +### 关键澄清 + +> **100 个 R 文件的定位:基础组件,不是学习资料** +> +> - LLM 的主要工作是"编排"这些工具 +> - 只有在运行报错时,才进行"靶向修改" +> - 修改的目标是修复错误,而非重写工具 + +### 行动号召 + +> **全军出击,拿下 MVP!** 🚀 + +--- + +*文档结束* + +*本文档为团队共识文档,所有成员应遵循此计划执行。* diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/07-Phase2A-智能化核心开发计划.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/07-Phase2A-智能化核心开发计划.md new file mode 100644 index 00000000..b1f158f7 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/07-Phase2A-智能化核心开发计划.md @@ -0,0 +1,755 @@ +# SSA-Pro Phase 2A:智能化核心开发计划 + +> **文档版本:** v1.1 +> **创建日期:** 2026-02-20 +> **最后更新:** 2026-02-20(纳入架构审查反馈:暗礁预警 + Python/R 分工 + 执行时机) +> **目标:** 以终为始,完成智能统计分析的核心能力 +> **里程碑:** 5 大核心组件跑通 + 7 个统计工具上线 + +--- + +## 1. 战略定位:为什么 Phase 2A 是 MVP 的核心? + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SSA-Pro 开发阶段定位 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Phase 1 ✅ 已完成 │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ 骨架搭建:前后端架构 + T检验端到端 + V11 UI │ +│ 验证目标:证明"能跑通" │ +│ │ +│ Phase 2A ⭐ 当前阶段(本计划) │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ 智能化核心:5大组件 + 7个工具 + 多工具流程规划 │ +│ 验证目标:证明"真正智能" │ +│ │ +│ Phase 2B+ 后续阶段 │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ 扩展完善:更多工具 + 咨询模式 + 配置中台 │ +│ 性质:复制粘贴 + 修修补补 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**核心观点:Phase 2A 完成后,智能化能力就完成了 85%+。后续工作主要是"量"的扩展,而非"质"的突破。** + +--- + +## 2. 以终为始:5 大核心组件 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 理想的智能统计分析系统 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 1. 意图理解器 (Intent Parser) ✅ 已有 │ │ +│ │ - Query Rewriter:医学术语 → 统计术语 │ │ +│ │ - Planner:Goal/Y/X/Design 四维提取 │ │ +│ │ - 不确定时追问澄清 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 2. 数据诊断器 (Data Diagnostician) 🆕 Phase 2A │ │ +│ │ │ │ +│ │ ⏱️ 时机 A:用户上传数据时 → Python (Tool C) │ │ +│ │ - 数据概况(行数、列数、类型推断) │ │ +│ │ - 缺失值分析(每列缺失数、缺失率) │ │ +│ │ - 异常值检测(IQR 方法) │ │ +│ │ - 基础描述统计(均值、中位数、标准差) │ │ +│ │ → 输出:DataProfile JSON(喂给 LLM 生成 SAP) │ │ +│ │ │ │ +│ │ ⏱️ 时机 B:执行核心计算前 → R Service (JIT 护栏) │ │ +│ │ - 正态性检验(Shapiro-Wilk,针对特定 Y 变量) │ │ +│ │ - 方差齐性检验(Levene,针对特定分组) │ │ +│ │ → 输出:StatisticalChecks JSON(决定方法选择) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 3. 路径规划器 (Pathway Planner) 🆕 Phase 2A │ │ +│ │ - LLM 理解用户意图 + 数据特征 │ │ +│ │ - 规划 2-7 步分析流程 │ │ +│ │ - 选择合适的统计工具组合 │ │ +│ │ → 输出:多步骤执行计划 (workflow_steps[]) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 4. 流程执行器 (Workflow Executor) 🆕 Phase 2A │ │ +│ │ - 串联执行多个工具 │ │ +│ │ - 结果在步骤间传递 │ │ +│ │ - 护栏检查与自动降级 │ │ +│ │ - 实时进度反馈(SSE 推送) │ │ +│ │ → 输出:step_results[] │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 5. 结论生成器 (Conclusion Generator) ✅ 已有 │ │ +│ │ - Critic Agent:多步骤结果整合 │ │ +│ │ - 论文级结论模板 │ │ +│ │ - 方法学说明 + 局限性声明 │ │ +│ │ → 输出:综合分析报告 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.1 组件状态汇总 + +| 组件 | 状态 | Phase 2A 任务 | +|------|------|--------------| +| 1. 意图理解器 | ✅ 已有 | 优化 Prompt,增强追问能力 | +| 2. 数据诊断器 | 🆕 待做 | **核心开发项** | +| 3. 路径规划器 | 🆕 待做 | **核心开发项** | +| 4. 流程执行器 | 🆕 待做 | **核心开发项** | +| 5. 结论生成器 | ✅ 已有 | 增强多步骤结果整合能力 | + +--- + +## 3. 工具清单:7 个统计方法 + +### 3.1 工具总览 + +| 序号 | 工具代码 | 名称 | 类别 | 状态 | 典型场景 | +|------|---------|------|------|------|---------| +| 1 | ST_T_TEST_IND | 独立样本 T 检验 | 参数检验 | ✅ 已完成 | 两组连续变量比较 | +| 2 | ST_MANN_WHITNEY | Mann-Whitney U 检验 | 非参数检验 | 🆕 待做 | 两组非正态/等级变量比较 | +| 3 | ST_CHI_SQUARE | 卡方检验 | 分类变量 | 🆕 待做 | 两个分类变量关联 | +| 4 | ST_LOGISTIC_BINARY | 二元 Logistic 回归 | 多因素分析 | 🆕 待做 | 二分类结局的危险因素 | +| 5 | ST_T_TEST_PAIRED | 配对 T 检验 | 参数检验 | 🆕 待做 | 前后对比/配对设计 | +| 6 | ST_CORRELATION | Pearson/Spearman 相关 | 相关分析 | 🆕 待做 | 两个连续变量相关性 | +| 7 | ST_DESCRIPTIVE | 描述性统计 | 基础统计 | 🆕 待做 | 数据概况/基线表 | + +### 3.2 工具选择逻辑(供 LLM 规划参考) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 工具选择决策树 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 研究目的是什么? │ +│ ├─ 描述数据 → ST_DESCRIPTIVE │ +│ ├─ 比较两组 │ +│ │ ├─ Y 是连续变量 │ +│ │ │ ├─ 独立样本 │ +│ │ │ │ ├─ 正态分布 → ST_T_TEST_IND │ +│ │ │ │ └─ 非正态 → ST_MANN_WHITNEY │ +│ │ │ └─ 配对样本 → ST_T_TEST_PAIRED │ +│ │ └─ Y 是分类变量 → ST_CHI_SQUARE │ +│ ├─ 分析相关性 │ +│ │ ├─ 正态分布 → ST_CORRELATION (Pearson) │ +│ │ └─ 非正态 → ST_CORRELATION (Spearman) │ +│ └─ 多因素分析 │ +│ └─ Y 是二分类 → ST_LOGISTIC_BINARY │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 各工具详细定义 + +#### 3.3.1 ST_MANN_WHITNEY(Mann-Whitney U 检验) + +| 属性 | 值 | +|------|---| +| **适用场景** | 两组独立样本比较,Y 为连续/等级变量,不满足正态性假设 | +| **输入参数** | `group_var`(分组变量), `value_var`(数值变量) | +| **输出** | U 统计量、Z 值、P 值、效应量 r、中位数对比 | +| **护栏** | 每组样本量 ≥ 5;分组变量必须是二分类 | +| **降级来源** | T 检验正态性不满足时自动降级 | + +#### 3.3.2 ST_CHI_SQUARE(卡方检验) + +| 属性 | 值 | +|------|---| +| **适用场景** | 两个分类变量的关联分析 | +| **输入参数** | `var1`(分类变量1), `var2`(分类变量2) | +| **输出** | χ² 统计量、自由度、P 值、Cramér's V | +| **护栏** | 期望频数 < 5 的格子不超过 20%;否则提示使用 Fisher | +| **自动降级** | 2×2 表且有格子 < 5 → Fisher 精确检验 | + +#### 3.3.3 ST_LOGISTIC_BINARY(二元 Logistic 回归) + +| 属性 | 值 | +|------|---| +| **适用场景** | 二分类结局变量的多因素分析 | +| **输入参数** | `outcome_var`(结局变量), `predictors`(预测变量列表), `confounders`(混杂因素,可选) | +| **输出** | OR、95% CI、P 值(每个变量)、模型拟合度(AIC、Hosmer-Lemeshow) | +| **护栏** | 事件数 / 自变量数 ≥ 10(EPV 规则);共线性检测(VIF < 5) | +| **高级** | 支持单因素 → 多因素两步分析 | + +#### 3.3.4 ST_T_TEST_PAIRED(配对 T 检验) + +| 属性 | 值 | +|------|---| +| **适用场景** | 配对设计,同一对象前后对比 | +| **输入参数** | `before_var`(前测变量), `after_var`(后测变量) | +| **输出** | 差值均值、t 统计量、P 值、Cohen's d、95% CI | +| **护栏** | 差值满足正态性;样本量 ≥ 10 | +| **降级** | 差值非正态 → Wilcoxon 符号秩检验 | + +#### 3.3.5 ST_CORRELATION(相关分析) + +| 属性 | 值 | +|------|---| +| **适用场景** | 两个连续变量的相关性分析 | +| **输入参数** | `var_x`, `var_y`, `method`(pearson/spearman/auto) | +| **输出** | 相关系数 r、P 值、散点图 | +| **护栏** | 样本量 ≥ 10;检测异常值影响 | +| **自动选择** | 两变量均正态 → Pearson;否则 → Spearman | + +#### 3.3.6 ST_DESCRIPTIVE(描述性统计) + +| 属性 | 值 | +|------|---| +| **适用场景** | 数据概况、基线特征表 | +| **输入参数** | `variables`(变量列表), `group_var`(可选,分组) | +| **输出** | 连续变量:均值±SD、中位数(IQR);分类变量:n(%) | +| **特殊** | 自动识别变量类型;支持分组对比 | + +--- + +## 4. 数据流架构(含执行时机) + +### 4.1 完整数据流 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 完整数据流(含执行时机) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 用户上传数据 │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⏱️ 时机 A: Python (Tool C) │ │ +│ │ → DataProfile JSON │ │ +│ │ - 列类型、缺失率、异常值 │ │ +│ │ - 唯一值、基础描述统计 │ │ +│ └──────────────────┬──────────────────┘ │ +│ │ 喂给 LLM │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 意图理解器 + 路径规划器 (LLM) │ │ +│ │ → SAP: Group=Gender, Value=GLU │ │ +│ │ → Workflow: [描述统计, T检验] │ │ +│ └──────────────────┬──────────────────┘ │ +│ │ 用户确认执行 │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ⏱️ 时机 B: R Service (JIT 护栏) │ │ +│ │ → 针对 GLU 做正态性检验 │ │ +│ │ → 针对 Gender 分组做方差齐性检验 │ │ +│ │ → StatisticalChecks JSON │ │ +│ └──────────────────┬──────────────────┘ │ +│ │ 根据结果决策 │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 正态 + 方差齐 → T 检验 │ │ +│ │ 非正态 → Mann-Whitney │ │ +│ │ 方差不齐 → Welch T 检验 │ │ +│ └──────────────────┬──────────────────┘ │ +│ ▼ │ +│ 执行核心统计 → 生成结果 → 结论生成器 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 执行时机说明 + +| 时机 | 触发条件 | 执行层 | 目的 | +|------|---------|--------|------| +| **时机 A** | 用户上传数据 | Python (Tool C) | 让 LLM 知道"数据长什么样" | +| **时机 B** | 用户确认执行前 | R Service | JIT 护栏:检验统计假设是否满足 | + +> ⚠️ **关键原则**:上传时不知道 Y 是什么,不能对全表做正态性检验。只有 LLM 确定了 Group=X, Value=Y 后,才能在执行前做 JIT 护栏检验。 + +### 4.3 Python (Tool C) DataProfile 接口 + +```typescript +// 新增接口:POST /api/dc/profile +interface DataProfileRequest { + sessionId: string; // Tool C 会话 ID +} + +interface DataProfileResponse { + columns: Array<{ + name: string; + type: 'numeric' | 'categorical' | 'datetime' | 'text'; + missingCount: number; + missingRate: number; + uniqueCount: number; + // 数值列 + mean?: number; + median?: number; + std?: number; + min?: number; + max?: number; + outlierCount?: number; + // 分类列 + topValues?: Array<{ value: string; count: number }>; + }>; + summary: { + totalRows: number; + totalColumns: number; + numericColumns: number; + categoricalColumns: number; + overallMissingRate: number; + }; +} +``` + +### 4.4 R Service JIT 护栏接口 + +```typescript +// 在执行核心工具前调用 +interface JITGuardrailRequest { + sessionId: string; + toolCode: string; // e.g., "ST_T_TEST_IND" + params: { + groupVar: string; // e.g., "Gender" + valueVar: string; // e.g., "GLU" + }; +} + +interface JITGuardrailResponse { + checks: Array<{ + checkName: string; // e.g., "正态性检验" + passed: boolean; + pValue: number; + recommendation: string; + }>; + suggestedTool: string; // 如果检验不通过,建议的替代工具 + canProceed: boolean; +} +``` + +--- + +## 5. 开发任务清单 + +### 5.0 前置任务:数据库 Schema 更新 + +> **重要**:遵循 `docs/04-开发规范/09-数据库开发规范.md`,使用 Prisma Migrate + +#### 新增表设计(方案 2:清晰结构) + +```prisma +/// SSA 多步骤流程 +model SsaWorkflow { + id String @id @default(uuid()) + sessionId String @map("session_id") + messageId String? @map("message_id") /// 关联的计划消息 + status String @default("pending") /// pending | running | completed | partial | error + totalSteps Int @map("total_steps") + completedSteps Int @default(0) @map("completed_steps") + workflowPlan Json @map("workflow_plan") /// 原始计划 JSON + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + session SsaSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + steps SsaWorkflowStep[] + + @@index([sessionId], map: "idx_ssa_workflow_session") + @@map("ssa_workflows") + @@schema("ssa_schema") +} + +/// SSA 流程步骤 +model SsaWorkflowStep { + id String @id @default(uuid()) + workflowId String @map("workflow_id") + stepOrder Int @map("step_order") + toolCode String @map("tool_code") + toolName String @map("tool_name") + status String @default("pending") /// pending | running | success | warning | error | skipped + inputParams Json? @map("input_params") + guardrailChecks Json? @map("guardrail_checks") /// JIT 护栏检验结果 + outputResult Json? @map("output_result") + errorInfo Json? @map("error_info") + executionMs Int? @map("execution_ms") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + + workflow SsaWorkflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + + @@index([workflowId], map: "idx_ssa_workflow_step_workflow") + @@map("ssa_workflow_steps") + @@schema("ssa_schema") +} +``` + +#### SsaSession 扩展字段 + +```prisma +model SsaSession { + // ... 现有字段 ... + + // 🆕 新增字段 + dataProfile Json? @map("data_profile") /// Python 生成的 DataProfile + + // 🆕 新增关系 + workflows SsaWorkflow[] +} +``` + +#### 迁移任务 + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 🆕 备份开发数据库 | 0.5h | P0 | - | +| 🆕 修改 schema.prisma(新增表 + 字段) | 1h | P0 | 备份 | +| 🆕 执行 `prisma migrate dev --name add_ssa_workflow_tables` | 0.5h | P0 | Schema | +| 🆕 检查生成的迁移 SQL | 0.5h | P0 | 迁移 | +| 🆕 本地测试 | 1h | P0 | SQL | +| 🆕 更新 Prisma Client 类型 | 0.5h | P0 | 测试 | + +**预估总工时:4h(约 0.5 天)** + +--- + +### 5.1 阶段一:R 工具扩展(6 个新工具) + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 实现 ST_MANN_WHITNEY(Mann-Whitney U 检验) | 4h | P0 | - | +| 实现 ST_CHI_SQUARE(卡方检验) | 4h | P0 | - | +| 实现 ST_LOGISTIC_BINARY(二元 Logistic 回归) | 6h | P0 | - | +| 实现 ST_T_TEST_PAIRED(配对 T 检验) | 3h | P1 | - | +| 实现 ST_CORRELATION(相关分析) | 3h | P1 | - | +| 实现 ST_DESCRIPTIVE(描述性统计) | 4h | P1 | - | +| 统一 run_analysis() 入口 + 护栏函数 | 4h | P0 | 以上全部 | + +**预估总工时:28h(约 4 天)** + +### 5.2 阶段二:数据诊断器(Python + R 分工) + +#### 5.2.1 时机 A:Python DataProfile(上传时) + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 🆕 扩展 Tool C Python:新增 `/api/dc/profile` 端点 | 4h | P0 | - | +| 🆕 实现 DataProfileService 后端服务(调用 Tool C) | 3h | P0 | Python 端点 | +| 🆕 实现前端 DataProfile 展示卡片 | 3h | P0 | 后端服务 | +| 🆕 集成到 SSA 上传数据流程 | 2h | P0 | 前端卡片 | + +#### 5.2.2 时机 B:R JIT 护栏(执行前) + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 🆕 实现 R 正态性检验函数(Shapiro-Wilk) | 2h | P0 | - | +| 🆕 实现 R 方差齐性检验函数(Levene) | 2h | P0 | - | +| 🆕 集成 JIT 护栏到 WorkflowExecutorService | 2h | P0 | R 函数 | +| 🆕 实现自动方法降级逻辑 | 2h | P0 | JIT 护栏 | + +**预估总工时:20h(约 2.5 天)** + +### 5.3 阶段三:路径规划器(多工具流程规划) + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 设计 WorkflowPlan 数据结构 | 2h | P0 | - | +| 实现 WorkflowPlannerService(LLM 规划) | 6h | P0 | 数据结构 | +| 设计规划 Prompt(工具选择逻辑) | 4h | P0 | 7个工具定义 | +| 实现后端 /plan 接口返回多步骤计划 | 3h | P0 | Planner | +| 实现前端多步骤计划展示卡片 | 4h | P0 | 后端接口 | + +**预估总工时:19h(约 2.5 天)** + +### 5.4 阶段四:流程执行器(串联执行) + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 实现 WorkflowExecutorService | 6h | P0 | 所有 R 工具 | +| 实现步骤间结果传递逻辑 | 3h | P0 | Executor | +| 实现 SSE 实时进度推送 | 3h | P0 | Executor | +| 实现后端 /execute 接口支持多步骤 | 3h | P0 | SSE | +| 实现前端多步骤进度展示 | 4h | P0 | 后端接口 | +| 实现执行中断/重试机制 | 2h | P1 | 基础执行 | + +**预估总工时:21h(约 3 天)** + +### 5.5 阶段五:结论生成器增强 + +| 任务 | 预估 | 优先级 | 依赖 | +|------|------|--------|------| +| 增强 Critic Prompt(多步骤结果整合) | 3h | P0 | - | +| 实现多步骤结果汇总逻辑 | 2h | P0 | Executor | +| 增强报告模板(方法学 + 局限性) | 2h | P1 | - | + +**预估总工时:7h(约 1 天)** + +--- + +## 6. 开发计划时间表 + +``` +Week 1:R 工具扩展 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Day 1-2: ST_MANN_WHITNEY + ST_CHI_SQUARE +Day 3-4: ST_LOGISTIC_BINARY(复杂度最高) +Day 5: ST_T_TEST_PAIRED + ST_CORRELATION + ST_DESCRIPTIVE + +Week 2:核心组件开发 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Day 1-2: 数据诊断器(ST_QUALITY_REPORT + DataQualityService) +Day 3-4: 路径规划器(WorkflowPlannerService + Prompt) +Day 5: 流程执行器(WorkflowExecutorService) + +Week 3:联调与打磨 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Day 1-2: 前端多步骤 UI + SSE 实时进度 +Day 3: 结论生成器增强 +Day 4-5: 端到端联调测试 + Bug 修复 +``` + +**总预估:3 周(15 个工作日)** + +--- + +## 7. 验收标准 + +### 7.1 功能验收 + +| 验收项 | 标准 | +|--------|------| +| 工具覆盖 | 7 个工具全部可用 | +| 数据诊断 | 上传数据后自动生成质量核查报告 | +| 流程规划 | LLM 能根据用户问题规划 2-7 步流程 | +| 串联执行 | 多步骤顺序执行,实时显示进度 | +| 结果整合 | 生成包含所有步骤结果的综合报告 | + +### 7.2 场景验收(必须通过) + +| 场景 | 预期流程 | +|------|---------| +| "比较两组血压" | 描述统计 → T检验 → (非正态时)Mann-Whitney | +| "分析吸烟与肺癌的关系" | 描述统计 → 卡方检验 | +| "哪些因素影响糖尿病" | 描述统计 → 单因素Logistic → 多因素Logistic | +| "治疗前后血压变化" | 描述统计 → 配对T检验 | +| "年龄与血压相关吗" | 描述统计 → 相关分析 | + +### 7.3 性能验收 + +| 指标 | 标准 | +|------|------| +| 数据诊断耗时 | < 5 秒(200行数据) | +| 流程规划耗时 | < 3 秒(LLM 响应) | +| 单步骤执行耗时 | < 10 秒 | +| 完整流程耗时 | < 60 秒(5 步骤) | + +--- + +## 8. 风险与应对 + +| 风险 | 影响 | 应对策略 | +|------|------|---------| +| LLM 规划不准确 | 工具选择错误 | 增加护栏校验 + 人工确认 | +| R 工具开发延期 | 阻塞后续开发 | 可并行开发,先做简单工具 | +| 多步骤执行失败 | 流程中断 | 实现重试机制 + 部分结果保存 | +| 结果传递复杂 | 步骤间数据格式不匹配 | 定义统一的 StepResult 结构 | + +--- + +## 9. 🚨 工程规范(架构审查暗礁预警) + +> **来源:** 架构审查报告,避免开发过程中踩坑 + +### 9.1 暗礁 1:R 脚本输出裁剪(防止 LLM Token 超载) + +**问题**:R 脚本返回的 JSON 可能包含原始全量数据(如残差数组、原始数据点),导致 LLM Token 超载、响应极慢甚至报错。 + +**强制规范**: + +```r +# ❌ 错误示例:返回原始数据 +result <- list( + p_value = 0.032, + residuals = residuals(model), # 可能有几百个值! + raw_data = df # 原始数据! +) + +# ✅ 正确示例:只返回精简结果 +result <- list( + p_value = 0.032, + t_statistic = 2.15, + df = 48, + ci_lower = 0.5, + ci_upper = 2.3, + effect_size = 0.62, + # 图表只返回精简坐标点(最多 100 个点) + plot_data = head(plot_points, 100) +) +``` + +**检查清单**: +- [ ] 禁止返回 `residuals`、`fitted.values` 等长数组 +- [ ] 禁止返回原始数据 `df` 或 `raw_data` +- [ ] 图表坐标点限制在 100 个以内 +- [ ] JSON 输出大小不超过 50KB + +--- + +### 9.2 暗礁 3:容错管道(支持部分成功) + +**问题**:多步骤执行时,一步失败导致整个流程崩溃,用户丢失已成功的结果。 + +**强制规范**: + +```typescript +// WorkflowExecutorService 必须实现容错管道 +interface StepResult { + step: number; + toolCode: string; + status: 'success' | 'warning' | 'error'; + result?: any; + error?: { + code: string; + message: string; + userHint: string; + }; + executionMs: number; +} + +// ✅ 正确实现:每步结果独立保存 +async executeWorkflow(workflow: WorkflowStep[]): Promise { + const results: StepResult[] = []; + + for (const step of workflow) { + try { + const result = await this.executeStep(step); + results.push({ ...step, status: 'success', result }); + } catch (error) { + // ⚠️ 关键:失败不中断,记录错误继续 + results.push({ + ...step, + status: 'error', + error: this.formatError(error) + }); + + // 只有关键错误才中断 + if (this.isCriticalError(error)) break; + } + } + + return results; // 返回部分成功的结果 +} +``` + +**前端展示**: +``` +✅ 描述性统计 (执行成功, 点击查看) +✅ 正态性检验 (执行成功, 点击查看) +❌ T检验 (执行失败: 方差为0, 点击查看原因) +⏸️ Mann-Whitney (已跳过) +``` + +--- + +### 9.3 SSE 消息格式规范 + +**强制格式**: + +```typescript +interface SSEMessage { + type: 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete'; + step: number; + toolCode: string; + toolName: string; + status: 'running' | 'success' | 'error' | 'skipped'; + message: string; + progress?: number; // 0-100,可选 + result?: any; // 步骤完成时的结果 + error?: { + code: string; + message: string; + userHint: string; + }; + timestamp: string; +} +``` + +**示例消息序列**: + +```json +{"type":"step_start","step":1,"toolCode":"ST_DESCRIPTIVE","toolName":"描述性统计","status":"running","message":"正在生成描述性统计...","timestamp":"2026-02-20T10:00:00Z"} + +{"type":"step_complete","step":1,"toolCode":"ST_DESCRIPTIVE","toolName":"描述性统计","status":"success","message":"描述性统计完成","result":{...},"timestamp":"2026-02-20T10:00:02Z"} + +{"type":"step_start","step":2,"toolCode":"ST_T_TEST_IND","toolName":"独立样本T检验","status":"running","message":"正在执行正态性检验(JIT护栏)...","timestamp":"2026-02-20T10:00:02Z"} + +{"type":"step_progress","step":2,"toolCode":"ST_T_TEST_IND","toolName":"独立样本T检验","status":"running","message":"正态性检验通过,执行T检验...","progress":50,"timestamp":"2026-02-20T10:00:03Z"} + +{"type":"step_complete","step":2,"toolCode":"ST_T_TEST_IND","toolName":"独立样本T检验","status":"success","message":"T检验完成","result":{...},"timestamp":"2026-02-20T10:00:05Z"} + +{"type":"workflow_complete","status":"success","message":"分析流程执行完成,共2个步骤","timestamp":"2026-02-20T10:00:05Z"} +``` + +**前端渲染效果**: +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 执行日志 │ +├─────────────────────────────────────────────────────────────────┤ +│ [10:00:00] ▶ 正在生成描述性统计... │ +│ [10:00:02] ✅ 描述性统计完成 │ +│ [10:00:02] ▶ 正在执行正态性检验(JIT护栏)... │ +│ [10:00:03] ▶ 正态性检验通过,执行T检验... │ +│ [10:00:05] ✅ T检验完成 │ +│ [10:00:05] 🎉 分析流程执行完成,共2个步骤 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 10. Phase 2A 完成后的系统能力 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 2A 完成后的 SSA-Pro │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ✅ 用户能做什么: │ +│ ────────────────────────────────────────────────────────────── │ +│ • 上传数据,自动获得数据质量报告 │ +│ • 用自然语言描述分析需求 │ +│ • 系统自动规划多步骤分析流程 │ +│ • 一键执行,实时看到每步进度 │ +│ • 获得包含完整方法学说明的论文级报告 │ +│ │ +│ ✅ 系统具备的智能: │ +│ ────────────────────────────────────────────────────────────── │ +│ • 理解医学术语,翻译为统计需求 │ +│ • 根据数据特征自动调整方法(正态→非参数) │ +│ • 规划完整分析路径,而非单一方法 │ +│ • 整合多步骤结果,生成综合结论 │ +│ │ +│ ✅ 后续扩展方向: │ +│ ────────────────────────────────────────────────────────────── │ +│ • 增加更多统计方法(复制工具模板即可) │ +│ • 增加咨询模式(无数据也能获得 SAP) │ +│ • 增加配置中台(量产工具管理) │ +│ • Phase 3 靶向代码修改(架构已预埋) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 11. 总结 + +**Phase 2A 是 SSA-Pro MVP 的真正内核。** + +完成 Phase 2A 后: +- 5 大核心组件全部跑通 +- 7 个统计方法可用 +- 多工具流程规划能力上线 +- 数据质量核查报告上线 +- **智能化能力完成 85%+** + +后续工作(Phase 2B+)主要是: +- 扩展更多统计方法(复制粘贴) +- 增加咨询模式(锦上添花) +- 配置中台(量产效率) + +**这才是以终为始的 MVP 开发策略。** diff --git a/docs/03-业务模块/SSA-智能统计分析/04-开发计划/08-Block-based动态结果渲染开发计划.md b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/08-Block-based动态结果渲染开发计划.md new file mode 100644 index 00000000..69e4d765 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/04-开发计划/08-Block-based动态结果渲染开发计划.md @@ -0,0 +1,153 @@ +# Block-based 动态结果渲染开发计划 + +> **版本:** v1.0 +> **创建日期:** 2026-02-20 +> **状态:** 📋 待开始 +> **优先级:** P0(架构级改进,影响所有后续工具开发) +> **关联文档:** `06-开发记录/SSA-Pro 动态结果渲染与通信协议规范.md` + +--- + +## 1. 背景与动机 + +### 当前痛点 + +目前每个 R 工具返回独特的数据结构,导致: +- **R 端**:每个工具自定义 `results` 字段(T检验有 `group_stats`,Logistic有 `coefficients`,描述性统计有 `variables`) +- **Node.js 后端**:用 `...response.data.results` 硬展开,手动拼装 `plots`、`result_table` 等字段 +- **前端**:为每种工具写 `if (isDescriptive)` / `if (r?.coefficients)` 等分支渲染逻辑 +- **导出报告**:Word 导出同样需要为每种工具写独立的构建逻辑 + +**核心矛盾**:R 工具数量增长(当前 7 个,目标 100+),但前端/后端的维护成本线性增长。 + +### 目标 + +采用 Block-based 协议,实现: +- R 端输出标准化的 `report_blocks` 数组 +- Node.js 零维护透传 +- 前端一个 `DynamicReport` 组件渲染所有工具的结果 +- Word 导出一个 `exportBlocksToWord` 函数处理所有工具 + +--- + +## 2. 技术方案 + +### 2.1 四种 Block 类型 + +| Block 类型 | 用途 | 典型场景 | +|-----------|------|---------| +| `markdown` | 文本解读、警告、结论 | AI 统计解读、护栏警告、方法说明 | +| `table` | 三线表、矩阵 | 分组统计表、回归系数表、列联表、描述性统计表 | +| `image` | 可视化图表 | 箱线图、森林图、直方图、马赛克图 | +| `key_value` | 核心统计量高亮 | P值、统计量、效应量、AIC | + +### 2.2 协议结构 + +```json +{ + "status": "success", + "trace_log": ["..."], + "reproducible_code": "library(ggplot2)...", + "report_blocks": [ + { + "type": "key_value", + "title": "核心指标", + "items": [ + {"label": "统计方法", "value": "Independent T-Test"}, + {"label": "t 值", "value": "2.45"}, + {"label": "P 值", "value": "0.015", "status": "significant"} + ] + }, + { + "type": "table", + "title": "Table 1. 分组统计", + "data": { + "headers": ["Group", "N", "Mean ± SD"], + "rows": [["Drug", "60", "14.5 ± 3.2"], ["Placebo", "60", "8.2 ± 2.8"]] + } + }, + { + "type": "image", + "title": "Figure 1. 分布对比", + "format": "base64", + "src": "iVBORw0KGgo...", + "caption": "箱线图展示两组分布" + }, + { + "type": "markdown", + "content": "**AI 解读:** 两组差异具有统计学意义 (P = 0.015)..." + } + ] +} +``` + +--- + +## 3. 实施计划 + +### Phase 1:基础设施(1 天) + +| 任务 | 层级 | 说明 | 预估 | +|------|------|------|------| +| 1.1 创建 `DynamicReport.tsx` 组件 | 前端 | 4 个 Block 渲染子组件 | 2h | +| 1.2 创建 `exportBlocksToWord.ts` | 前端 | Block 数组 → Word 文档 | 2h | +| 1.3 后端透传 `report_blocks` | 后端 | WorkflowExecutorService 透传 | 0.5h | +| 1.4 R 端辅助函数库 `block_helpers.R` | R | `make_table_block()`, `make_image_block()` 等 | 1h | +| 1.5 SSAWorkspacePane 集成 | 前端 | 优先读 `report_blocks`,fallback 旧逻辑 | 1h | + +### Phase 2:R 工具改造(2 天) + +| 任务 | R 工具 | 当前 Block 输出内容 | 预估 | +|------|--------|-------------------|------| +| 2.1 | `descriptive.R` | summary key_value + 数值变量 table + 分类变量 table + 直方图/柱状图 image | 1.5h | +| 2.2 | `t_test_ind.R` | 核心指标 key_value + 分组统计 table + AI 解读 markdown + 箱线图 image | 1h | +| 2.3 | `logistic_binary.R` | 模型拟合 key_value + 回归系数 table + 森林图 image + AI 解读 markdown | 1.5h | +| 2.4 | `chi_square.R` | 核心指标 key_value + 列联表 table + 马赛克图 image | 1h | +| 2.5 | `correlation.R` | 核心指标 key_value + 散点图 image + AI 解读 markdown | 1h | +| 2.6 | `t_test_paired.R` | 核心指标 key_value + 配对差值 table + image | 1h | +| 2.7 | `mann_whitney.R` | 核心指标 key_value + 分组统计 table + image | 1h | + +### Phase 3:清理旧代码(0.5 天) + +| 任务 | 说明 | 预估 | +|------|------|------| +| 3.1 移除 SSAWorkspacePane 中的自定义渲染逻辑 | 删除 `isDescriptive`、`r?.coefficients` 等分支 | 1h | +| 3.2 移除 useAnalysis.ts 中的自定义导出逻辑 | 删除 `isDescStep`、`classifyExportVar` 等 | 1h | +| 3.3 移除后端 result 字段展开逻辑 | 删除 `...response.data.results` 拼装 | 0.5h | +| 3.4 更新文档 | 更新开发指南、R 服务开发规范 | 0.5h | + +--- + +## 4. 向后兼容策略 + +采用 **渐进式迁移**,不一刀切: + +1. R 工具同时返回 `results`(旧)和 `report_blocks`(新) +2. 前端优先读 `report_blocks`,如果不存在则 fallback 到旧的自定义渲染 +3. 全部 7 个工具迁移完成后,再删除旧渲染代码 + +--- + +## 5. 预期收益 + +| 指标 | 改造前 | 改造后 | +|------|--------|--------| +| 新增 1 个 R 工具的前端开发量 | 50-100 行自定义渲染 + 50 行导出逻辑 | 0 行 | +| 新增 1 个 R 工具的后端开发量 | 10-20 行字段映射 | 0 行 | +| 前端结果渲染组件数 | N 个(每种工具一个分支) | 1 个 DynamicReport | +| Word 导出维护成本 | 每种工具单独处理 | 1 个通用函数 | + +--- + +## 6. 总工时估算 + +| Phase | 工时 | +|-------|------| +| Phase 1:基础设施 | 6.5h | +| Phase 2:R 工具改造 | 8h | +| Phase 3:清理旧代码 | 3h | +| **合计** | **~17.5h(约 2.5 天)** | + +--- + +**下一步:** 待用户确认后开始 Phase 1 实施。 diff --git a/docs/03-业务模块/SSA-智能统计分析/05-测试文档/phase2a_e2e_test.ts b/docs/03-业务模块/SSA-智能统计分析/05-测试文档/phase2a_e2e_test.ts new file mode 100644 index 00000000..8d8f7dbf --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/05-测试文档/phase2a_e2e_test.ts @@ -0,0 +1,581 @@ +/** + * SSA Phase 2A 端到端自动化测试 + * + * 测试层次: + * - Layer 1: R 服务 - 7 个统计工具 + * - Layer 2: Python DataProfile API + * - Layer 3: Node.js 工作流 API + * - Layer 4: 完整场景测试 + * + * 运行方式:npx tsx phase2a_e2e_test.ts + */ + +import axios, { AxiosInstance } from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +// ==================== 配置 ==================== +const CONFIG = { + // 服务地址 + R_SERVICE_URL: 'http://localhost:8082', + PYTHON_SERVICE_URL: 'http://localhost:8081', + BACKEND_URL: 'http://localhost:3000', + + // 测试账号 + USERNAME: '13800000001', + PASSWORD: '123456', + + // 测试数据文件 + TEST_CSV_PATH: path.join(__dirname, 'test.csv') +}; + +// ==================== 工具函数 ==================== + +function log(emoji: string, message: string, details?: any) { + console.log(`${emoji} ${message}`); + if (details) { + console.log(' ', JSON.stringify(details, null, 2).split('\n').slice(0, 10).join('\n ')); + } +} + +function success(message: string, details?: any) { + log('✅', message, details); +} + +function error(message: string, details?: any) { + log('❌', message, details); +} + +function info(message: string) { + log('ℹ️', message); +} + +function section(title: string) { + console.log('\n' + '='.repeat(60)); + console.log(` ${title}`); + console.log('='.repeat(60) + '\n'); +} + +// 加载测试数据 +function loadTestData(): Record[] { + const csvContent = fs.readFileSync(CONFIG.TEST_CSV_PATH, 'utf-8'); + const lines = csvContent.trim().split('\n'); + const headers = lines[0].split(','); + + const data: Record[] = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(','); + const row: Record = {}; + for (let j = 0; j < headers.length; j++) { + const val = values[j]; + if (val === '' || val === undefined) { + row[headers[j]] = null; + } else if (!isNaN(Number(val))) { + row[headers[j]] = Number(val); + } else { + row[headers[j]] = val; + } + } + data.push(row); + } + + return data; +} + +// ==================== Layer 1: R 服务测试 ==================== + +async function testRService() { + section('Layer 1: R 服务测试(7 个统计工具)'); + + const rClient = axios.create({ + baseURL: CONFIG.R_SERVICE_URL, + timeout: 60000 + }); + + const data = loadTestData(); + info(`测试数据: ${data.length} 行, ${Object.keys(data[0]).length} 列`); + + const results: { tool: string; status: string; time: number; error?: string }[] = []; + + // 1. 健康检查 + try { + const health = await rClient.get('/health'); + success('R 服务健康检查通过', { + version: health.data.version, + tools_loaded: health.data.tools_loaded + }); + } catch (e: any) { + error('R 服务连接失败', e.message); + return { passed: false, results }; + } + + // 构建数据源 + const dataSource = { type: 'inline', data }; + + // 2. ST_DESCRIPTIVE - 描述性统计 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_DESCRIPTIVE', { + data_source: dataSource, + params: { + variables: ['age', 'bmi', 'time'], + group_var: 'sex' + } + }); + results.push({ tool: 'ST_DESCRIPTIVE', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + success('ST_DESCRIPTIVE (描述性统计)', { + summary: res.data.results?.summary + }); + } else { + error('ST_DESCRIPTIVE 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_DESCRIPTIVE', status: 'error', time: 0, error: e.message }); + error('ST_DESCRIPTIVE 异常', e.message); + } + + // 3. ST_T_TEST_IND - 独立样本 T 检验 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_T_TEST_IND', { + data_source: dataSource, + params: { group_var: 'sex', value_var: 'age' }, + guardrails: { check_normality: true } + }); + results.push({ tool: 'ST_T_TEST_IND', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + success('ST_T_TEST_IND (独立样本T检验)', { + p_value: res.data.results?.p_value_fmt, + t: res.data.results?.statistic + }); + } else { + error('ST_T_TEST_IND 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_T_TEST_IND', status: 'error', time: 0, error: e.message }); + error('ST_T_TEST_IND 异常', e.message); + } + + // 4. ST_MANN_WHITNEY - Mann-Whitney U 检验 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_MANN_WHITNEY', { + data_source: dataSource, + params: { group_var: 'sex', value_var: 'bmi' } + }); + results.push({ tool: 'ST_MANN_WHITNEY', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + success('ST_MANN_WHITNEY (Mann-Whitney U)', { + p_value: res.data.results?.p_value_fmt, + U: res.data.results?.statistic_U + }); + } else { + error('ST_MANN_WHITNEY 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_MANN_WHITNEY', status: 'error', time: 0, error: e.message }); + error('ST_MANN_WHITNEY 异常', e.message); + } + + // 5. ST_CHI_SQUARE - 卡方检验 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_CHI_SQUARE', { + data_source: dataSource, + params: { var1: 'sex', var2: 'smoke' } + }); + results.push({ tool: 'ST_CHI_SQUARE', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + success('ST_CHI_SQUARE (卡方检验)', { + p_value: res.data.results?.p_value_fmt, + chi2: res.data.results?.statistic + }); + } else { + error('ST_CHI_SQUARE 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_CHI_SQUARE', status: 'error', time: 0, error: e.message }); + error('ST_CHI_SQUARE 异常', e.message); + } + + // 6. ST_CORRELATION - 相关分析 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_CORRELATION', { + data_source: dataSource, + params: { var_x: 'age', var_y: 'bmi', method: 'auto' } + }); + results.push({ tool: 'ST_CORRELATION', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + success('ST_CORRELATION (相关分析)', { + r: res.data.results?.statistic, + p_value: res.data.results?.p_value_fmt, + method: res.data.results?.method_code + }); + } else { + error('ST_CORRELATION 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_CORRELATION', status: 'error', time: 0, error: e.message }); + error('ST_CORRELATION 异常', e.message); + } + + // 7. ST_LOGISTIC_BINARY - 二元 Logistic 回归 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_LOGISTIC_BINARY', { + data_source: dataSource, + params: { + outcome_var: 'Yqol', // 二分类结局 + predictors: ['age', 'bmi', 'sex', 'smoke'] + } + }); + results.push({ tool: 'ST_LOGISTIC_BINARY', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + const sigCoeffs = res.data.results?.coefficients?.filter((c: any) => c.significant) || []; + success('ST_LOGISTIC_BINARY (Logistic回归)', { + n_predictors: res.data.results?.n_predictors, + significant_vars: sigCoeffs.length, + AIC: res.data.results?.model_fit?.aic + }); + } else { + error('ST_LOGISTIC_BINARY 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_LOGISTIC_BINARY', status: 'error', time: 0, error: e.message }); + error('ST_LOGISTIC_BINARY 异常', e.message); + } + + // 8. ST_T_TEST_PAIRED - 配对 T 检验(使用两个连续变量模拟) + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/skills/ST_T_TEST_PAIRED', { + data_source: dataSource, + params: { before_var: 'mouth_open', after_var: 'bucal_relax' }, + guardrails: { check_normality: true } + }); + results.push({ tool: 'ST_T_TEST_PAIRED', status: res.data.status, time: Date.now() - start }); + if (res.data.status === 'success') { + success('ST_T_TEST_PAIRED (配对T检验)', { + p_value: res.data.results?.p_value_fmt, + mean_diff: res.data.results?.descriptive?.difference?.mean + }); + } else { + error('ST_T_TEST_PAIRED 失败', res.data); + } + } catch (e: any) { + results.push({ tool: 'ST_T_TEST_PAIRED', status: 'error', time: 0, error: e.message }); + error('ST_T_TEST_PAIRED 异常', e.message); + } + + // 9. JIT 护栏测试 + try { + const start = Date.now(); + const res = await rClient.post('/api/v1/guardrails/jit', { + data_source: dataSource, + tool_code: 'ST_T_TEST_IND', + params: { group_var: 'sex', value_var: 'age' } + }); + if (res.data.status === 'success') { + success('JIT 护栏检查', { + checks: res.data.checks?.length, + all_passed: res.data.all_checks_passed + }); + } + } catch (e: any) { + error('JIT 护栏检查异常', e.message); + } + + // 汇总 + const passed = results.filter(r => r.status === 'success').length; + info(`\nR 服务测试完成: ${passed}/${results.length} 通过`); + + return { passed: passed === results.length, results }; +} + +// ==================== Layer 2: Python DataProfile 测试 ==================== + +async function testPythonDataProfile() { + section('Layer 2: Python DataProfile 测试'); + + const pythonClient = axios.create({ + baseURL: CONFIG.PYTHON_SERVICE_URL, + timeout: 60000 + }); + + const data = loadTestData(); + + // 健康检查 + try { + const health = await pythonClient.get('/api/health'); + success('Python 服务健康检查通过', { status: health.data.status }); + } catch (e: any) { + error('Python 服务连接失败', e.message); + return { passed: false }; + } + + // DataProfile 测试 + try { + const start = Date.now(); + const res = await pythonClient.post('/api/ssa/data-profile', { + data, + max_unique_values: 20, + include_quality_score: true + }); + + if (res.data.success) { + const profile = res.data.profile; + const quality = res.data.quality; + + success('DataProfile 生成成功', { + rows: profile.summary.totalRows, + columns: profile.summary.totalColumns, + numeric: profile.summary.numericColumns, + categorical: profile.summary.categoricalColumns, + missing_rate: profile.summary.overallMissingRate + '%', + quality_score: quality?.score, + quality_grade: quality?.grade, + execution_time: res.data.execution_time + 's' + }); + + // 显示部分列画像 + info('部分列画像:'); + for (const col of profile.columns.slice(0, 5)) { + console.log(` - ${col.name} [${col.type}]: 缺失${col.missingRate}%`); + if (col.type === 'numeric') { + console.log(` 均值=${col.mean}, SD=${col.std}`); + } + } + + return { passed: true, profile, quality }; + } else { + error('DataProfile 生成失败', res.data); + return { passed: false }; + } + } catch (e: any) { + error('DataProfile 请求异常', e.message); + return { passed: false }; + } +} + +// ==================== Layer 3: Node.js 后端 API 测试 ==================== + +async function testBackendAPI() { + section('Layer 3: Node.js 后端 API 测试'); + + const client = axios.create({ + baseURL: CONFIG.BACKEND_URL, + timeout: 60000 + }); + + let token = ''; + + // 1. 登录获取 Token + try { + const loginRes = await client.post('/api/v1/auth/login', { + phone: CONFIG.USERNAME, + password: CONFIG.PASSWORD + }); + + if (loginRes.data.token || loginRes.data.data?.token) { + token = loginRes.data.token || loginRes.data.data?.token; + success('登录成功', { token: token.substring(0, 20) + '...' }); + client.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } else { + error('登录失败', loginRes.data); + return { passed: false }; + } + } catch (e: any) { + error('登录请求异常', e.response?.data || e.message); + return { passed: false }; + } + + const data = loadTestData(); + let sessionId = ''; + let workflowId = ''; + + // 2. 创建 SSA 会话 + try { + const createRes = await client.post('/api/v1/ssa/sessions', { + title: 'Phase2A 自动化测试', + dataPayload: data, + dataSchema: { + columns: Object.keys(data[0]).map(name => ({ + name, + type: typeof data[0][name] === 'number' ? 'numeric' : 'categorical' + })) + } + }); + + if (createRes.data.session?.id || createRes.data.id) { + sessionId = createRes.data.session?.id || createRes.data.id; + success('SSA 会话创建成功', { sessionId }); + } else { + error('SSA 会话创建失败', createRes.data); + return { passed: false, token }; + } + } catch (e: any) { + error('创建会话异常', e.response?.data || e.message); + return { passed: false, token }; + } + + // 3. 生成 DataProfile + try { + const profileRes = await client.post('/api/v1/ssa/workflow/profile', { + sessionId + }); + + if (profileRes.data.success) { + success('会话 DataProfile 生成成功', { + rows: profileRes.data.profile?.summary?.totalRows + }); + } else { + info('DataProfile 生成跳过(可能已存在)'); + } + } catch (e: any) { + info('DataProfile 端点可能不存在: ' + e.message); + } + + // 4. 规划工作流 + try { + const planRes = await client.post('/api/v1/ssa/workflow/plan', { + sessionId, + userQuery: '比较不同性别的年龄和BMI差异' + }); + + if (planRes.data.success && planRes.data.plan) { + const plan = planRes.data.plan; + success('工作流规划成功', { + goal: plan.goal, + steps: plan.steps?.length, + tools: plan.steps?.map((s: any) => s.toolCode) + }); + + // 从数据库获取 workflowId + // 暂时跳过执行测试,因为需要查询数据库获取 workflowId + info('工作流规划完成,跳过执行测试(需要 workflowId)'); + } else { + error('工作流规划失败', planRes.data); + } + } catch (e: any) { + error('工作流规划异常', e.response?.data || e.message); + } + + return { passed: true, token, sessionId }; +} + +// ==================== Layer 4: 完整场景测试 ==================== + +async function testFullScenario() { + section('Layer 4: 完整场景测试(验收标准)'); + + info('根据 Phase 2A 验收标准进行测试...\n'); + + const scenarios = [ + { + name: '场景1: 比较两组血压', + query: '比较两组的数值差异', + expectedFlow: ['描述统计', 'T检验/Mann-Whitney'] + }, + { + name: '场景2: 分析分类变量关联', + query: '分析性别与吸烟的关系', + expectedFlow: ['描述统计', '卡方检验'] + }, + { + name: '场景3: 多因素分析', + query: '哪些因素影响结局', + expectedFlow: ['描述统计', '单因素分析', 'Logistic回归'] + }, + { + name: '场景4: 相关性分析', + query: '年龄与BMI相关吗', + expectedFlow: ['描述统计', '相关分析'] + } + ]; + + for (const scenario of scenarios) { + console.log(`📋 ${scenario.name}`); + console.log(` 查询: "${scenario.query}"`); + console.log(` 预期流程: ${scenario.expectedFlow.join(' → ')}`); + console.log(''); + } + + info('场景验收需要在前端界面手动验证'); + + return { passed: true }; +} + +// ==================== 主函数 ==================== + +async function main() { + console.log('\n'); + console.log('╔══════════════════════════════════════════════════════════╗'); + console.log('║ SSA Phase 2A 端到端自动化测试 ║'); + console.log('║ 测试时间: ' + new Date().toISOString().slice(0, 19) + ' ║'); + console.log('╚══════════════════════════════════════════════════════════╝'); + + const results = { + layer1_r_service: { passed: false, details: null as any }, + layer2_python_profile: { passed: false, details: null as any }, + layer3_backend_api: { passed: false, details: null as any }, + layer4_full_scenario: { passed: false, details: null as any } + }; + + // Layer 1 + try { + results.layer1_r_service = await testRService(); + } catch (e: any) { + error('Layer 1 测试异常', e.message); + } + + // Layer 2 + try { + results.layer2_python_profile = await testPythonDataProfile(); + } catch (e: any) { + error('Layer 2 测试异常', e.message); + } + + // Layer 3 + try { + results.layer3_backend_api = await testBackendAPI(); + } catch (e: any) { + error('Layer 3 测试异常', e.message); + } + + // Layer 4 + try { + results.layer4_full_scenario = await testFullScenario(); + } catch (e: any) { + error('Layer 4 测试异常', e.message); + } + + // 最终汇总 + section('测试汇总'); + + const layers = [ + { name: 'Layer 1: R 服务', result: results.layer1_r_service }, + { name: 'Layer 2: Python DataProfile', result: results.layer2_python_profile }, + { name: 'Layer 3: Node.js 后端', result: results.layer3_backend_api }, + { name: 'Layer 4: 完整场景', result: results.layer4_full_scenario } + ]; + + let allPassed = true; + for (const layer of layers) { + const status = layer.result.passed ? '✅ 通过' : '❌ 失败'; + console.log(`${status} ${layer.name}`); + if (!layer.result.passed) allPassed = false; + } + + console.log('\n' + '─'.repeat(60)); + if (allPassed) { + console.log('🎉 Phase 2A 端到端测试全部通过!'); + } else { + console.log('⚠️ 部分测试未通过,请检查上述错误'); + } + console.log('─'.repeat(60) + '\n'); +} + +// 运行 +main().catch(console.error); diff --git a/docs/03-业务模块/SSA-智能统计分析/05-测试文档/run_e2e_test.js b/docs/03-业务模块/SSA-智能统计分析/05-测试文档/run_e2e_test.js new file mode 100644 index 00000000..a7295344 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/05-测试文档/run_e2e_test.js @@ -0,0 +1,536 @@ +/** + * SSA Phase 2A 端到端自动化测试 + * + * 运行方式:node run_e2e_test.js + * + * 测试层次: + * - Layer 1: R 服务 - 7 个统计工具 + * - Layer 2: Python DataProfile API + * - Layer 3: Node.js 后端 API + */ + +const http = require('http'); +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +// ==================== 配置 ==================== +const CONFIG = { + R_SERVICE_URL: 'http://localhost:8082', + PYTHON_SERVICE_URL: 'http://localhost:8000', + BACKEND_URL: 'http://localhost:3000', + USERNAME: '13800000001', + PASSWORD: '123456', + TEST_CSV_PATH: path.join(__dirname, 'test.csv') +}; + +// ==================== HTTP 请求工具 ==================== + +function httpRequest(urlStr, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + const isHttps = url.protocol === 'https:'; + const lib = isHttps ? https : http; + + const reqOptions = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + timeout: options.timeout || 60000 + }; + + const req = lib.request(reqOptions, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + resolve({ status: res.statusCode, data: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode, data }); + } + }); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + if (options.body) { + req.write(JSON.stringify(options.body)); + } + req.end(); + }); +} + +// ==================== 日志工具 ==================== + +function log(emoji, message, details) { + console.log(`${emoji} ${message}`); + if (details) { + const str = JSON.stringify(details, null, 2); + const lines = str.split('\n').slice(0, 10); + console.log(' ', lines.join('\n ')); + } +} + +const success = (msg, details) => log('✅', msg, details); +const error = (msg, details) => log('❌', msg, details); +const info = (msg) => log('ℹ️', msg); + +function section(title) { + console.log('\n' + '='.repeat(60)); + console.log(` ${title}`); + console.log('='.repeat(60) + '\n'); +} + +// ==================== 加载测试数据 ==================== + +function loadTestData() { + const csvContent = fs.readFileSync(CONFIG.TEST_CSV_PATH, 'utf-8'); + const lines = csvContent.trim().split('\n'); + const headers = lines[0].split(','); + + const data = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(','); + const row = {}; + for (let j = 0; j < headers.length; j++) { + const val = values[j]; + if (val === '' || val === undefined) { + row[headers[j]] = null; + } else if (!isNaN(Number(val))) { + row[headers[j]] = Number(val); + } else { + row[headers[j]] = val; + } + } + data.push(row); + } + + return data; +} + +// ==================== Layer 1: R 服务测试 ==================== + +async function testRService() { + section('Layer 1: R 服务测试(7 个统计工具 + JIT 护栏)'); + + const data = loadTestData(); + info(`测试数据: ${data.length} 行, ${Object.keys(data[0]).length} 列`); + + const results = []; + const dataSource = { type: 'inline', data }; + + // 1. 健康检查 + try { + const res = await httpRequest(`${CONFIG.R_SERVICE_URL}/health`); + success('R 服务健康检查', { + version: res.data.version, + tools_loaded: res.data.tools_loaded + }); + } catch (e) { + error('R 服务连接失败', e.message); + return { passed: false, results }; + } + + // 测试配置 + const tests = [ + { + name: 'ST_DESCRIPTIVE (描述性统计)', + endpoint: '/api/v1/skills/ST_DESCRIPTIVE', + body: { + data_source: dataSource, + params: { variables: ['age', 'bmi', 'time'], group_var: 'sex' } + }, + extract: (r) => ({ summary: r.results?.summary }) + }, + { + name: 'ST_T_TEST_IND (独立样本T检验)', + endpoint: '/api/v1/skills/ST_T_TEST_IND', + body: { + data_source: dataSource, + params: { group_var: 'sex', value_var: 'age' }, + guardrails: { check_normality: true } + }, + extract: (r) => ({ p: r.results?.p_value_fmt, t: r.results?.statistic }) + }, + { + name: 'ST_MANN_WHITNEY (Mann-Whitney U)', + endpoint: '/api/v1/skills/ST_MANN_WHITNEY', + body: { + data_source: dataSource, + params: { group_var: 'sex', value_var: 'bmi' } + }, + extract: (r) => ({ p: r.results?.p_value_fmt, U: r.results?.statistic_U }) + }, + { + name: 'ST_CHI_SQUARE (卡方检验)', + endpoint: '/api/v1/skills/ST_CHI_SQUARE', + body: { + data_source: dataSource, + params: { var1: 'sex', var2: 'smoke' } + }, + extract: (r) => ({ p: r.results?.p_value_fmt, chi2: r.results?.statistic }) + }, + { + name: 'ST_CORRELATION (相关分析)', + endpoint: '/api/v1/skills/ST_CORRELATION', + body: { + data_source: dataSource, + params: { var_x: 'age', var_y: 'bmi', method: 'auto' } + }, + extract: (r) => ({ r: r.results?.statistic, p: r.results?.p_value_fmt, method: r.results?.method_code }) + }, + { + name: 'ST_LOGISTIC_BINARY (Logistic回归)', + endpoint: '/api/v1/skills/ST_LOGISTIC_BINARY', + body: { + data_source: dataSource, + params: { outcome_var: 'Yqol', predictors: ['age', 'bmi', 'sex', 'smoke'] } + }, + extract: (r) => ({ + n: r.results?.n_predictors, + sig: r.results?.coefficients?.filter(c => c.significant)?.length, + aic: r.results?.model_fit?.aic + }) + }, + { + name: 'ST_T_TEST_PAIRED (配对T检验)', + endpoint: '/api/v1/skills/ST_T_TEST_PAIRED', + body: { + data_source: dataSource, + params: { before_var: 'mouth_open', after_var: 'bucal_relax' }, + guardrails: { check_normality: true } + }, + extract: (r) => ({ p: r.results?.p_value_fmt, diff: r.results?.descriptive?.difference?.mean }) + }, + { + name: 'JIT 护栏检查', + endpoint: '/api/v1/guardrails/jit', + body: { + data_source: dataSource, + tool_code: 'ST_T_TEST_IND', + params: { group_var: 'sex', value_var: 'age' } + }, + extract: (r) => ({ checks: r.checks?.length, all_passed: r.all_checks_passed }) + } + ]; + + for (const test of tests) { + try { + const start = Date.now(); + const res = await httpRequest(`${CONFIG.R_SERVICE_URL}${test.endpoint}`, { + method: 'POST', + body: test.body + }); + + const elapsed = Date.now() - start; + + if (res.data.status === 'success') { + const extracted = test.extract(res.data); + success(`${test.name} (${elapsed}ms)`, extracted); + results.push({ name: test.name, status: 'success', time: elapsed }); + } else { + error(`${test.name} 失败`, res.data.error || res.data.message); + results.push({ name: test.name, status: 'failed', time: elapsed }); + } + } catch (e) { + error(`${test.name} 异常`, e.message); + results.push({ name: test.name, status: 'error', error: e.message }); + } + } + + const passed = results.filter(r => r.status === 'success').length; + info(`\nR 服务测试: ${passed}/${results.length} 通过`); + + return { passed: passed === results.length, results, passedCount: passed, total: results.length }; +} + +// ==================== Layer 2: Python DataProfile 测试 ==================== + +async function testPythonDataProfile() { + section('Layer 2: Python DataProfile 测试'); + + const data = loadTestData(); + + // 健康检查 + try { + const res = await httpRequest(`${CONFIG.PYTHON_SERVICE_URL}/api/health`); + success('Python 服务健康检查', { status: res.data.status }); + } catch (e) { + error('Python 服务连接失败', e.message); + return { passed: false }; + } + + // DataProfile 测试 + try { + const start = Date.now(); + const res = await httpRequest(`${CONFIG.PYTHON_SERVICE_URL}/api/ssa/data-profile`, { + method: 'POST', + body: { + data, + max_unique_values: 20, + include_quality_score: true + } + }); + + const elapsed = Date.now() - start; + + if (res.data.success) { + const profile = res.data.profile; + const quality = res.data.quality; + + success(`DataProfile 生成成功 (${elapsed}ms)`, { + rows: profile.summary.totalRows, + columns: profile.summary.totalColumns, + numeric: profile.summary.numericColumns, + categorical: profile.summary.categoricalColumns, + missing_rate: profile.summary.overallMissingRate + '%', + quality_score: quality?.score, + quality_grade: quality?.grade + }); + + info('部分列画像:'); + for (const col of profile.columns.slice(0, 5)) { + console.log(` - ${col.name} [${col.type}]: 缺失${col.missingRate}%`); + } + + return { passed: true, profile, quality }; + } else { + error('DataProfile 生成失败', res.data); + return { passed: false }; + } + } catch (e) { + error('DataProfile 请求异常', e.message); + return { passed: false }; + } +} + +// ==================== Layer 3: Node.js 后端 API 测试 ==================== + +async function testBackendAPI() { + section('Layer 3: Node.js 后端 API 测试'); + + let token = ''; + + // 1. 登录 + try { + const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/auth/login/password`, { + method: 'POST', + body: { + phone: CONFIG.USERNAME, + password: CONFIG.PASSWORD + } + }); + + if (res.data.success && res.data.data?.tokens?.accessToken) { + token = res.data.data.tokens.accessToken; + success('登录成功', { + user: res.data.data.user?.name, + token: token.substring(0, 20) + '...' + }); + } else if (res.data.data?.token) { + token = res.data.data.token; + success('登录成功', { token: token.substring(0, 20) + '...' }); + } else { + error('登录失败', res.data); + return { passed: false }; + } + } catch (e) { + error('登录请求异常', e.message); + return { passed: false }; + } + + const data = loadTestData(); + let sessionId = ''; + + // 2. 创建 SSA 会话 + try { + const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/sessions`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: { + title: 'Phase2A 自动化测试 - ' + new Date().toISOString().slice(0, 19), + dataPayload: data, + dataSchema: { + columns: Object.keys(data[0]).map(name => ({ + name, + type: typeof data[0][name] === 'number' ? 'numeric' : 'categorical' + })) + } + } + }); + + if (res.data.sessionId || res.data.session?.id || res.data.data?.id || res.data.id) { + sessionId = res.data.sessionId || res.data.session?.id || res.data.data?.id || res.data.id; + success('SSA 会话创建成功', { sessionId }); + } else { + error('SSA 会话创建失败', res.data); + return { passed: false, token }; + } + } catch (e) { + error('创建会话异常', e.message); + return { passed: false, token }; + } + + // 3. 生成 DataProfile(通过会话) + try { + const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/profile`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: { sessionId } + }); + + if (res.data.success) { + success('会话 DataProfile 生成成功', { + rows: res.data.profile?.summary?.totalRows + }); + } else { + info('DataProfile: ' + (res.data.message || '跳过')); + } + } catch (e) { + info('DataProfile 端点: ' + e.message); + } + + // 4. 规划工作流 + let workflowId = ''; + try { + const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/plan`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: { + sessionId, + userQuery: '比较不同性别的年龄和BMI差异,分析是否存在统计学显著差异' + } + }); + + if (res.data.success && res.data.plan) { + const plan = res.data.plan; + workflowId = res.data.workflowId || ''; + success('工作流规划成功', { + workflowId, + goal: plan.goal, + steps: plan.steps?.length, + tools: plan.steps?.map(s => s.toolCode) + }); + } else { + error('工作流规划失败', res.data); + return { passed: false, token, sessionId }; + } + } catch (e) { + error('工作流规划异常', e.message); + return { passed: false, token, sessionId }; + } + + // 5. 执行工作流(如果有 workflowId) + if (workflowId) { + try { + info(`执行工作流 ${workflowId}...`); + const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/${workflowId}/execute`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + timeout: 120000 // 2 分钟超时 + }); + + if (res.data.success) { + success('工作流执行成功', { + status: res.data.result?.status, + completedSteps: res.data.result?.completedSteps, + totalSteps: res.data.result?.totalSteps, + hasConclusion: !!res.data.result?.conclusion + }); + + // 显示结论摘要 + if (res.data.result?.conclusion) { + info('结论摘要:'); + console.log(' ' + (res.data.result.conclusion.summary || '').substring(0, 200) + '...'); + } + } else { + error('工作流执行失败', res.data); + } + } catch (e) { + error('工作流执行异常', e.message); + } + } + + return { passed: true, token, sessionId, workflowId }; +} + +// ==================== 主函数 ==================== + +async function main() { + console.log('\n'); + console.log('╔══════════════════════════════════════════════════════════╗'); + console.log('║ SSA Phase 2A 端到端自动化测试 ║'); + console.log('║ ' + new Date().toISOString().slice(0, 19) + ' ║'); + console.log('╚══════════════════════════════════════════════════════════╝'); + + const results = { + layer1: { passed: false }, + layer2: { passed: false }, + layer3: { passed: false } + }; + + // Layer 1: R 服务 + try { + results.layer1 = await testRService(); + } catch (e) { + error('Layer 1 测试异常', e.message); + } + + // Layer 2: Python DataProfile + try { + results.layer2 = await testPythonDataProfile(); + } catch (e) { + error('Layer 2 测试异常', e.message); + } + + // Layer 3: Node.js 后端 + try { + results.layer3 = await testBackendAPI(); + } catch (e) { + error('Layer 3 测试异常', e.message); + } + + // 最终汇总 + section('测试汇总报告'); + + const layers = [ + { name: 'Layer 1: R 服务 (7工具+护栏)', result: results.layer1 }, + { name: 'Layer 2: Python DataProfile', result: results.layer2 }, + { name: 'Layer 3: Node.js 后端 API', result: results.layer3 } + ]; + + let allPassed = true; + for (const layer of layers) { + const status = layer.result.passed ? '✅ 通过' : '❌ 失败'; + let extra = ''; + if (layer.result.passedCount !== undefined) { + extra = ` (${layer.result.passedCount}/${layer.result.total})`; + } + console.log(`${status} ${layer.name}${extra}`); + if (!layer.result.passed) allPassed = false; + } + + console.log('\n' + '─'.repeat(60)); + if (allPassed) { + console.log('🎉 Phase 2A 端到端测试全部通过!系统已准备就绪!'); + } else { + console.log('⚠️ 部分测试未通过,请检查上述错误并修复'); + } + console.log('─'.repeat(60) + '\n'); + + process.exit(allPassed ? 0 : 1); +} + +main().catch(err => { + console.error('测试脚本执行失败:', err); + process.exit(1); +}); diff --git a/docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv b/docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv new file mode 100644 index 00000000..c0104256 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv @@ -0,0 +1,312 @@ +sex,smoke,age,bmi,mouth_open,bucal_relax,toot_morph,root_number,root_curve,lenspace,denseratio,Pglevel,Pgverti,Winter,presyp,flap,operation,time,surgage,Yqol,times +2,2,27,23.1,4.2,4.4,3,2,2,4.43,0.554,1,1,1,1,2,2,15.5,2,0,23.25 +1,1,24,16.7,3.5,4.7,3,2,2,7.02,0.657,1,1,1,2,2,1,4.73,2,0,7.095 +1,1,30,21.5,4.2,4.7,1,1,1,9.82,0.665,2,1,1,2,2,1,4.27,2,1,6.405 +2,1,26,20.7,4.6,3.7,1,2,2,6.37,0.675,2,1,,,,,5.33,2,0,7.995 +2,1,20,20.4,3.7,3.6,3,3,1,6.43,0.685,2,1,,,,,6.3,2,1,9.45 +2,1,18,21.9,4.8,4.9,3,2,2,6.46,0.687,2,1,1,2,2,1,4.88,2,0,7.32 +1,1,26,19.8,4,4.1,3,2,1,5.1,0.697,2,1,1,2,2,1,8.33,1,1,12.495 +1,1,26,23.7,4.4,3.3,3,2,1,7.15,0.732,2,1,1,2,2,1,3.25,2,1,4.875 +2,1,23,24.3,5,3.9,3,2,2,6.31,0.776,2,1,1,2,2,1,3.67,2,0,5.505 +1,2,42,18,4.2,,,2,1,6.9,0.796,2,1,1,2,2,1,10.33,3,1,15.495 +2,1,21,21.8,4.2,,,2,2,5.76,0.806,1,1,2,2,1,2,8.67,2,1,13.005 +1,1,18,27.2,4.2,5,3,2,1,6.12,0.814,3,1,1,2,2,1,3.38,2,1,5.07 +2,1,24,24.2,4,4.2,1,1,1,8.87,0.82,2,1,1,2,2,1,3.33,2,0,4.995 +2,2,24,23.3,5,4.9,1,2,1,6.37,0.822,3,2,2,2,3,3,19.75,1,1,29.625 +1,2,22,14.2,5.4,4.1,2,2,1,9.49,0.826,3,2,1,1,3,2,17.3,2,0,25.95 +1,1,,,4.4,5,1,,,9.23,0.83,2,2,1,2,3,2,12.35,3,1,18.525 +1,1,,,4.2,4,3,,,5.49,0.835,1,1,1,2,2,1,4.97,1,0,7.455 +2,2,,,4.2,4.3,3,,,6.02,0.849,1,1,1,2,2,1,3.33,2,1,4.995 +1,1,27,21.7,4.3,5,1,3,1,6,0.854,2,1,2,1,3,3,34.42,3,1,51.63 +1,1,22,21.1,3.6,4.1,1,1,1,5.93,0.867,2,1,2,1,3,3,23.15,3,1,34.725 +1,1,34,30.6,3.9,5,1,2,1,6.65,0.878,3,1,3,2,3,3,26.5,1,1,39.75 +2,2,24,24.9,4.2,4.5,3,2,2,4.42,0.892,2,2,3,2,3,3,20.5,1,1,30.75 +2,2,25,14.7,4.4,4,1,2,2,4.9,0.893,2,1,2,2,3,3,24.5,1,1,36.75 +1,1,22,19.3,4.3,4.6,3,2,1,5.54,,,,1,1,2,1,3.83,2,0,5.745 +1,1,23,16.2,5.5,5,3,2,2,5.23,,,,2,2,3,3,15.08,1,1,22.62 +2,1,38,38.3,4.5,5.4,3,2,2,4.82,,,,2,2,3,2,17.93,2,1,26.895 +1,1,24,25.4,4.4,3.5,1,2,1,5.21,0.903,2,1,1,2,3,1,3.02,2,0,4.53 +1,2,21,13.9,4,4.8,1,1,1,4.27,0.905,2,1,3,1,2,2,20.25,3,1,30.375 +1,1,26,25,4.6,4.5,2,2,2,5.81,0.919,1,1,2,1,3,2,26.32,3,1,39.48 +2,2,30,41.1,5,4.8,3,2,2,4.6,0.919,1,1,2,2,1,2,9.05,1,0,13.575 +1,1,18,14.4,4.3,3.9,3,2,2,10.18,0.922,3,1,2,2,3,3,6.83,1,0,10.245 +1,1,27,15.8,4,4.5,1,2,1,6.57,0.927,3,1,1,2,2,2,11.58,1,1,17.37 +2,1,20,46.6,3.9,4.7,3,2,1,6.81,0.928,2,1,2,2,3,3,25.33,3,1,37.995 +2,1,29,23.4,4.5,4,1,2,1,22.26,0.93,2,1,3,2,3,3,16.68,2,0,25.02 +2,2,34,35.8,3,4.5,1,1,1,7.48,0.934,2,1,2,2,3,2,5.33,1,0,7.995 +1,1,33,29.9,4.1,4.4,1,1,1,6.4,0.934,1,1,2,1,3,2,8.67,1,0,13.005 +1,1,35,21,3.9,3.6,3,3,1,7.09,0.937,2,1,2,2,1,3,21.38,2,0,32.07 +1,2,27,23.7,4.4,4.5,1,1,1,6.09,0.938,2,1,1,2,2,1,6.5,2,1,9.75 +1,1,20,20.7,4.3,4.7,1,2,1,9.5,0.941,2,1,3,2,3,2,10.53,2,0,15.795 +1,2,26,16.1,5.6,5.4,1,1,1,7.52,0.946,2,1,1,2,3,1,5.5,2,1,8.25 +2,1,33,45,4,5,1,2,1,9.59,0.95,2,1,1,2,2,1,3.67,1,0,5.505 +1,2,28,19.7,3.6,5,1,1,1,6.97,0.953,1,1,1,1,2,1,6.08,1,1,9.12 +1,2,24,17.9,3.7,4.9,2,3,1,4.99,0.957,1,1,2,2,3,2,30.62,1,1,45.93 +2,2,33,31.8,4.2,5,1,2,2,4.62,0.958,2,1,3,2,3,3,14.67,1,0,22.005 +1,1,34,15.6,4,5.2,1,1,1,5.4,0.96,1,2,2,1,2,1,5.42,3,0,8.13 +1,,,,4,5.1,1,1,1,4.92,0.965,,,2,2,3,3,11.67,3,1,17.505 +1,,,,4.2,4.8,1,2,1,10.33,0.965,,,3,2,3,3,24.3,1,0,36.45 +2,,,,5.2,6,3,2,1,8.21,0.966,,,4,2,2,1,4.67,1,0,7.005 +1,,,,4.4,3.4,1,2,1,4.39,0.969,,,3,1,3,3,24.58,2,1,36.87 +1,1,27,23,4.5,4.7,1,2,1,4.04,0.971,,,2,2,3,2,14.82,2,0,22.23 +1,1,36,18,4.2,4.7,3,2,1,5.47,0.972,1,1,2,2,3,2,14.83,1,0,22.245 +1,1,18,21.8,4.4,5,3,3,2,6.14,0.978,2,1,1,1,2,1,3.5,3,0,5.25 +2,1,29,21.1,4.2,3.9,1,1,1,6.49,0.979,2,1,3,2,3,2,24.67,3,1,37.005 +1,1,24,17.3,3.9,4,1,1,1,8.72,0.98,2,1,3,2,3,3,15.28,2,0,22.92 +2,1,39,26.3,3.7,4,1,2,1,4.75,0.981,2,1,1,2,2,1,27.5,3,1,41.25 +1,2,20,20.8,5.1,4.7,1,1,1,5.62,0.981,1,3,2,2,3,1,6.5,1,0,9.75 +1,1,25,19,3.2,4.6,3,2,2,8.97,0.982,2,1,3,1,3,3,36.45,2,1,54.675 +2,1,32,31.6,4.2,4.9,3,2,1,5.05,0.985,3,1,2,1,2,2,8.33,1,0,12.495 +1,2,21,19.6,4.1,4.6,1,2,1,5.63,0.985,2,1,3,2,3,3,18.67,1,1,28.005 +1,1,24,13.6,3.7,5,1,2,1,9.44,0.986,3,2,3,1,3,2,32.12,2,1,48.18 +1,1,21,21.2,4.6,4.5,3,2,1,9.35,0.987,2,1,2,2,3,3,30.6,3,0,45.9 +1,1,27,23.4,4.9,3.5,1,3,1,6.89,0.989,1,1,1,2,2,1,10.5,3,0,15.75 +2,1,24,24.5,4.4,4.6,1,2,1,6.64,0.99,3,2,2,2,3,1,5.92,1,0,8.88 +1,2,22,16.7,4.5,4.4,1,2,2,6.39,0.996,2,1,1,2,2,1,4.37,2,0,6.555 +2,2,27,31.3,4.2,5.5,3,2,2,9.13,0.997,2,1,1,2,3,1,8.05,2,1,12.075 +2,2,23,24.4,4.5,4.8,3,2,2,4.72,0.997,2,1,2,1,3,2,10.5,3,0,15.75 +2,1,19,18.7,4.6,5,1,1,2,7.5,0.997,2,1,3,1,3,3,13.63,2,0,20.445 +2,2,29,20,3.7,3.8,3,2,1,6.57,0.998,2,1,3,2,3,3,15.27,2,1,22.905 +2,2,30,32.1,4.1,5,1,2,1,8.27,0.999,1,2,3,2,3,3,13.88,2,0,20.82 +2,1,26,27.5,4.1,4.9,1,2,1,7.36,1.002,2,1,3,2,3,3,26.67,2,0,40.005 +2,1,26,45,4.2,4.3,3,2,1,6.41,1.006,2,1,2,2,2,2,4.5,2,0,6.75 +2,2,20,34.8,4.7,5,3,2,2,4.88,1.009,2,1,1,2,1,1,4.33,1,1,6.495 +1,1,25,23.9,4.2,3.9,2,2,2,5.76,1.009,2,1,2,1,3,2,24.25,1,0,36.375 +1,2,29,29.9,4.2,4.3,1,2,1,6.54,1.009,2,1,2,2,3,2,21.3,3,0,31.95 +1,1,28,17.2,4.3,4.8,1,1,1,5.69,1.009,1,1,2,2,3,2,11.67,1,0,17.505 +1,2,33,21.4,4.1,4.6,1,1,1,7.01,1.009,2,1,3,2,3,3,8.5,1,1,12.75 +1,2,26,16.7,4.4,5.4,3,2,1,9.41,1.011,3,1,3,2,3,3,15.5,2,1,23.25 +1,1,23,15.8,4.1,4.5,3,2,2,5.24,1.012,1,1,2,2,3,2,7.78,2,0,11.67 +1,1,20,37.6,4.5,4.9,1,2,2,9.96,1.012,2,2,2,1,3,3,18.95,2,0,28.425 +2,1,30,28.3,4.3,4.6,3,2,1,5.61,1.013,2,1,1,2,2,2,5.5,3,0,8.25 +2,1,24,20.2,3.5,4.5,3,2,2,5.3,1.013,2,1,2,2,3,2,24.5,1,0,36.75 +1,2,33,13,4.6,4.8,2,1,1,6.03,1.014,3,3,1,2,3,3,18.17,1,1,27.255 +2,1,17,44.8,3.8,4.1,1,2,1,8.29,1.014,2,1,2,2,3,3,28.03,1,0,42.045 +1,1,25,16.9,4.3,4.5,3,2,1,4.74,1.014,2,1,3,1,3,2,7.42,1,0,11.13 +1,2,22,18.4,3.8,4,1,2,1,6.65,1.016,2,2,2,1,3,3,13.73,1,1,20.595 +1,1,19,21,3.8,4.2,1,1,1,4.35,1.016,2,2,3,1,3,3,18.68,2,0,28.02 +2,2,33,60.6,3.5,5.5,1,2,1,7.92,1.019,2,1,1,2,3,2,25.18,1,0,37.77 +1,1,32,29.3,3.7,4.7,1,2,1,4.71,1.019,2,1,2,2,3,3,12.67,1,1,19.005 +1,1,27,14.2,4.2,4.5,1,1,1,6.27,1.02,2,1,2,2,2,1,5.83,2,1,8.745 +2,1,25,24.8,4.8,4.2,3,2,2,8.4,1.022,2,1,2,2,2,3,7.1,2,1,10.65 +2,2,27,39.5,3.4,4.5,1,2,2,7.74,1.025,3,1,3,2,3,3,20.33,1,1,30.495 +2,1,29,30.7,3.7,4.7,1,2,1,5.43,1.026,2,1,2,2,3,3,18.8,1,1,28.2 +2,2,31,28.8,4.4,4.5,1,2,1,5.36,1.026,2,1,2,2,2,2,8.58,1,0,12.87 +2,2,27,19.7,3.5,4.5,2,2,2,9.19,1.026,3,1,2,1,3,2,13.25,2,1,19.875 +2,1,33,26.1,4.7,4.8,3,2,1,6.55,1.028,2,1,2,2,3,3,24.5,1,0,36.75 +2,1,24,31.4,5.6,5.9,1,1,1,6.03,1.028,1,2,2,2,3,1,8.67,3,0,13.005 +2,2,29,37.8,4.5,5.2,1,2,1,4.44,1.029,2,1,2,2,3,2,28.17,3,1,42.255 +1,2,23,21.2,4.4,5,1,1,1,16.97,1.031,3,2,3,2,3,3,18.37,2,1,27.555 +1,2,29,14.2,3.7,4,1,1,1,5.52,1.032,1,1,1,1,1,1,7.5,3,0,11.25 +1,1,25,18.9,4.1,4.5,1,1,1,5.25,1.033,1,2,1,2,3,1,3.95,2,0,5.925 +2,1,23,27.4,4.3,4.3,1,2,1,5.97,1.033,2,2,3,1,3,3,16.57,1,0,24.855 +1,1,38,21.4,3.8,5,1,2,1,5.14,1.035,2,1,2,2,3,2,25.67,1,0,38.505 +2,1,40,39.5,4.5,4.7,3,2,1,4.93,1.036,1,1,2,1,1,2,19.42,1,0,29.13 +1,1,38,13.8,3.6,4.1,1,1,1,4.94,1.037,3,1,2,2,1,2,10.52,1,1,15.78 +1,1,28,18.8,4.6,4.6,1,1,2,7.3,1.039,2,1,2,2,2,2,5.73,2,1,8.595 +2,1,18,38.6,4.9,4.1,3,2,1,5.12,1.039,2,1,2,2,3,3,30.18,1,1,45.27 +2,1,28,21.4,4.7,5.1,1,2,1,8.01,1.041,3,1,3,2,3,3,20.17,1,1,30.255 +1,1,27,26.4,4.3,4.4,1,2,1,5.77,1.042,1,1,1,1,2,1,3.33,1,0,4.995 +2,1,16,27.4,4.4,4.9,1,2,1,7.34,1.042,1,1,2,1,1,2,10.67,3,0,16.005 +2,1,18,21.7,5.3,4.6,1,2,1,11.05,1.043,2,3,2,1,3,3,14.5,1,0,21.75 +2,1,40,30.7,4.1,5.2,3,2,2,5.55,1.043,1,1,3,2,3,3,30.73,1,0,46.095 +2,2,42,35.4,4,5.2,3,2,1,6.11,1.043,2,1,3,2,1,3,11.08,2,0,16.62 +1,2,45,25.6,4.5,4.1,1,1,1,8.96,1.045,3,1,3,1,3,1,18.67,1,1,28.005 +2,1,38,27.1,4.5,4.8,3,2,1,6.76,1.046,2,1,2,2,1,3,10.5,2,1,15.75 +1,1,24,19.9,4.5,4.6,1,2,2,5.19,1.047,3,1,2,1,3,3,25.95,1,0,38.925 +1,2,21,18.3,4.4,4,1,2,1,8.04,1.048,2,1,2,1,3,3,13.03,2,1,19.545 +1,1,26,21.1,4.9,3.9,1,1,1,7.16,1.048,2,1,3,2,3,2,14.28,2,0,21.42 +1,1,20,14.2,4.5,4.7,1,1,1,5.14,1.049,3,1,2,2,3,2,10.33,3,1,15.495 +1,1,30,14.1,3.5,4.4,1,1,1,6.72,1.049,2,1,3,2,3,3,33.43,1,1,50.145 +1,1,24,13.5,3.2,4.3,1,3,1,7.92,1.05,2,1,3,2,3,3,15.67,2,1,23.505 +1,1,24,14.2,4.2,3.7,1,2,1,12.14,1.05,3,1,3,2,3,2,17.72,2,1,26.58 +1,1,23,12.8,4.9,3.5,1,2,1,4.92,1.051,2,1,3,1,3,3,40.4,3,0,60.6 +2,1,25,41.6,3.2,4.8,1,2,1,7.6,1.054,2,1,2,2,3,3,25.6,1,1,38.4 +1,1,28,11.4,3.8,4,1,2,1,4.28,1.054,1,1,3,2,3,2,18.5,3,0,27.75 +2,1,26,37.4,3.7,4,3,2,1,4.3,1.057,2,1,2,2,3,2,16.67,1,1,25.005 +1,1,33,18,3.7,4.8,3,2,1,11.27,1.059,3,1,3,2,3,3,40.17,1,1,60.255 +1,1,22,20.5,3.6,4.1,1,1,1,8.85,1.06,2,1,2,2,3,3,7.87,2,1,11.805 +2,1,29,39.5,4.3,4.5,2,2,1,5.85,1.061,1,2,1,2,3,1,15.17,2,0,22.755 +1,1,22,15.5,4.5,4.8,1,2,1,7.22,1.061,1,1,3,2,3,3,13.33,2,0,19.995 +2,1,29,35.1,4.7,4.8,2,2,2,5.31,1.062,1,1,1,2,3,1,5.33,2,0,7.995 +1,1,23,14.4,4.5,3.9,1,2,1,4.73,1.062,1,2,1,2,3,2,20.72,1,0,31.08 +1,1,18,19.5,4.2,5.7,1,2,2,5.79,1.062,2,1,2,2,3,3,20.22,1,0,30.33 +1,1,28,15.4,3.4,3.7,1,2,1,4.56,1.063,2,1,1,2,2,1,15.67,3,1,23.505 +1,2,35,147.3,4.7,4.6,1,1,1,5.95,1.063,2,1,2,2,1,2,8.5,2,1,12.75 +1,2,20,11.7,4.6,4.3,1,2,1,7.22,1.063,3,1,3,2,3,3,27.68,1,1,41.52 +2,2,36,30.7,4.5,5,2,2,1,7.55,1.066,3,1,3,2,3,3,22.17,1,0,33.255 +1,1,49,26.2,3.4,4.4,3,2,1,5.01,1.067,1,1,2,2,3,3,22.17,1,0,33.255 +2,2,26,45,4.2,4.3,2,2,1,6.13,1.068,1,1,2,1,1,2,6.9,2,0,10.35 +1,2,47,17.4,4.2,4.3,1,2,1,4.24,1.07,1,1,1,2,1,1,5.03,1,0,7.545 +1,2,23,16.7,4.4,3.6,1,1,1,6.49,1.07,3,1,2,1,3,2,18.83,2,1,28.245 +2,1,17,23.5,4.6,5,3,1,1,6.87,1.07,3,1,2,1,3,2,6.5,1,1,9.75 +1,1,30,15.5,4.7,4.2,1,2,1,6.37,1.07,2,1,2,2,2,2,6.48,1,0,9.72 +1,2,28,33.1,3.7,4.3,1,2,2,5.33,1.071,2,1,3,2,3,2,33.43,1,1,50.145 +1,1,24,18,3.5,4,1,2,2,7.33,1.072,3,1,2,2,3,3,32.58,3,1,48.87 +1,2,27,9.4,3.2,4.6,1,1,1,6.32,1.072,2,1,3,1,3,3,12.83,2,1,19.245 +1,1,31,18.6,4.3,4.5,1,2,2,5.67,1.074,2,1,2,2,2,2,6.83,1,1,10.245 +2,1,49,26.7,5,5.5,1,3,1,5.11,1.074,1,1,2,2,1,1,9.5,1,1,14.25 +1,1,26,18.1,4.2,4,1,1,1,2.97,1.074,1,1,2,1,2,1,8.33,1,0,12.495 +1,1,29,14.7,4.5,4.7,3,2,2,7.74,1.074,2,2,2,2,3,3,23.02,2,1,34.53 +1,1,27,20.9,4.6,5.2,1,2,1,13.96,1.075,3,1,2,2,3,3,20.4,1,0,30.6 +1,2,24,16.5,4.1,4.4,1,1,1,8.13,1.075,3,1,3,1,3,3,11.33,2,1,16.995 +1,1,37,24.7,3.7,4.5,2,1,1,6.46,1.077,1,1,1,2,2,1,3.47,2,0,5.205 +2,1,24,24.9,4,4.4,1,1,1,4.33,1.078,2,1,2,2,3,2,15.67,1,1,23.505 +1,1,32,25,3.6,4.1,1,3,1,9.77,1.078,2,1,3,2,3,3,35.75,1,1,53.625 +1,1,20,20.8,3.3,5,1,2,1,5.19,1.078,2,1,3,2,3,3,12.67,2,0,19.005 +1,1,19,12.8,3.7,3.7,1,2,1,10.19,1.079,2,2,3,2,3,3,29.25,1,0,43.875 +2,1,27,28.5,3.7,4.6,1,1,1,5.47,1.081,1,1,2,2,3,2,10.67,1,1,16.005 +2,1,27,43.3,4.4,4.6,3,2,2,6.34,1.083,1,1,1,2,2,2,18.33,1,0,27.495 +1,1,18,24,4.3,3.3,1,2,2,7.04,1.083,2,2,3,1,3,3,19.57,1,0,29.355 +1,1,41,26.1,3.6,3.9,3,2,1,9.69,1.085,3,1,2,2,2,3,14.57,2,0,21.855 +2,2,15,28,4.3,5,1,2,1,8.81,1.085,2,3,2,1,3,3,13.33,1,0,19.995 +1,1,24,18.3,4.1,4.2,1,1,1,6.04,1.085,2,1,3,1,3,2,27.5,3,0,41.25 +1,1,36,16.4,4.8,3.7,1,3,1,10.93,1.086,2,1,1,2,2,1,10.92,3,1,16.38 +1,1,26,17.6,4.6,4.7,1,2,2,6.15,1.087,3,1,2,2,1,2,10.52,3,1,15.78 +1,2,21,16.2,3.7,4.5,1,2,1,4.21,1.087,2,1,3,1,3,2,30.67,1,0,46.005 +1,1,39,22.2,3.8,4.2,1,2,2,6.96,1.088,2,1,2,1,3,2,18.5,2,0,27.75 +2,1,29,24,4.5,5.5,1,1,1,5.83,1.091,2,1,1,2,2,1,10.5,2,0,15.75 +2,1,30,42.1,4,5,1,2,1,6.94,1.092,1,1,1,2,2,1,4.5,1,0,6.75 +1,2,49,22.6,4.3,3.8,1,2,1,4.44,1.093,1,1,1,1,1,1,3.33,1,0,4.995 +1,1,30,15.5,3.6,4.9,2,2,2,6.02,1.093,2,1,3,2,3,2,24.83,3,1,37.245 +2,1,33,35.6,4.2,4.6,1,1,1,8.86,1.094,3,1,2,2,3,2,16.5,1,0,24.75 +1,2,32,17.4,3.8,4.5,1,1,1,6.36,1.094,2,1,3,2,3,3,13.5,3,0,20.25 +1,2,23,17,4.3,3.9,1,2,1,8.98,1.094,1,1,3,2,3,3,8.82,1,0,13.23 +1,2,45,16.1,4.4,4.5,1,1,1,7.01,1.095,3,2,3,2,3,2,16.9,1,1,25.35 +2,2,27,35.6,4.9,5.3,1,2,1,6.72,1.096,2,1,2,2,3,2,21.63,3,0,32.445 +2,1,27,33.8,4.5,5,1,2,1,7.43,1.098,3,1,3,2,3,3,14.83,1,1,22.245 +2,1,43,20.6,4,5,3,2,2,6.73,1.099,1,1,2,1,2,2,11.88,1,0,17.82 +1,2,28,23.1,3.4,4,1,1,1,11.76,1.1,3,2,3,2,2,3,27.83,2,1,41.745 +1,2,20,14.2,4.5,4.6,1,3,1,4.78,1.105,2,1,2,2,3,2,13.67,3,0,20.505 +1,1,29,22.9,5.3,4.3,1,1,1,10.94,1.105,2,2,2,2,3,3,17.85,2,0,26.775 +2,2,33,40.1,4.4,6,1,1,1,5.17,1.106,2,1,2,1,3,2,13.37,1,0,20.055 +2,2,28,45.8,4.5,5.5,1,2,1,4.15,1.108,2,1,2,2,3,3,11.52,2,0,17.28 +2,1,25,22.8,3.1,3.6,1,2,2,6.91,1.109,3,1,2,1,3,3,25.33,1,1,37.995 +2,2,26,23.7,5.4,5,1,1,1,7.43,1.109,2,1,2,2,2,3,17.68,2,1,26.52 +1,1,25,36.3,4.1,3.9,1,1,2,7.07,1.109,3,1,3,2,3,3,26.33,2,0,39.495 +2,2,30,46.5,4.7,5,3,2,1,5.07,1.11,2,1,2,1,3,3,23.67,2,1,35.505 +1,1,31,29.7,4.5,4.7,2,1,1,7.11,1.11,2,1,2,2,1,3,22.48,1,1,33.72 +1,2,27,40,3.5,3.8,1,2,1,8.99,1.111,3,3,1,2,3,1,6.67,3,1,10.005 +1,2,22,14.6,5,5.2,3,3,2,6.65,1.112,2,1,2,1,3,3,39.67,1,1,59.505 +2,2,24,27.2,5.1,4.9,1,2,1,6.46,1.113,3,2,3,1,3,3,28.17,1,0,42.255 +1,1,23,19.8,4.2,4.5,1,1,1,6.37,1.117,2,1,1,2,1,1,2.83,2,0,4.245 +1,1,26,16.7,3.6,4.6,2,2,1,5.78,1.117,2,1,2,2,1,2,2.92,1,0,4.38 +1,1,25,43.6,3.9,4.5,1,1,1,5.13,1.118,1,1,2,1,2,2,15.33,3,1,22.995 +2,1,24,39.5,4.4,4.6,1,2,2,4.86,1.118,1,1,2,2,2,1,5.17,1,0,7.755 +1,2,46,20,4.5,3.7,1,1,1,5.67,1.119,2,1,1,2,3,1,5.67,2,0,8.505 +1,1,21,17.6,4.5,4.8,1,2,1,7.18,1.119,1,2,1,1,3,2,15.67,1,0,23.505 +1,1,24,18.3,5,3.7,1,1,1,6.6,1.121,1,1,3,1,2,1,1.32,2,0,1.98 +1,2,25,16.7,4.2,4.6,1,2,1,6.73,1.122,2,1,2,2,2,1,10.5,2,1,15.75 +1,2,22,19.5,4,4.2,3,2,2,5.63,1.123,2,1,2,1,3,3,12.5,1,0,18.75 +1,1,23,19.8,4.4,4.6,1,2,1,5.86,1.124,2,1,2,2,3,2,10.33,2,1,15.495 +1,2,27,14.1,4.8,4.5,1,2,1,5.21,1.128,1,1,2,2,3,3,15.68,1,1,23.52 +1,1,29,13.8,3.5,4,1,2,2,5.61,1.129,2,1,3,1,3,3,38.67,3,1,58.005 +1,1,55,30.7,5,4.8,2,2,1,5.06,1.131,2,1,2,1,3,3,9.5,1,1,14.25 +1,1,26,12.1,3.8,3.7,1,1,1,6.79,1.131,3,1,2,1,1,2,6.83,1,0,10.245 +1,1,64,25.4,4.1,5,2,2,1,8.03,1.132,2,1,2,2,3,3,30.85,3,1,46.275 +1,1,20,21.2,4,4.6,1,2,1,4.43,1.132,1,2,2,2,3,1,8.25,1,1,12.375 +1,1,29,24.5,4.2,3.9,1,1,1,6.12,1.133,1,1,1,2,2,1,2.95,1,0,4.425 +1,2,30,18.2,3.6,4,2,1,1,8.16,1.134,3,1,3,2,3,2,12.25,1,1,18.375 +1,1,20,20.8,3.7,4.7,1,1,1,4.8,1.135,2,1,1,1,2,1,3.83,1,0,5.745 +2,1,45,45,5.9,4,1,2,2,6.52,1.136,3,2,2,1,3,3,40.5,2,0,60.75 +1,2,38,16.3,4.2,4,2,2,2,3.79,1.138,2,1,2,2,2,2,8.5,1,1,12.75 +2,2,31,38.2,3.6,4.2,1,2,1,8.89,1.138,2,2,3,2,3,3,40.5,1,0,60.75 +2,1,22,41.3,4,4.6,1,2,2,6.89,1.143,2,2,2,1,3,3,35.67,1,1,53.505 +2,1,26,31.2,5,5.5,1,2,1,6.89,1.144,2,1,1,2,1,1,7.83,1,1,11.745 +2,1,24,32,5,4.3,2,2,1,7.53,1.148,2,2,3,2,3,3,23.12,1,0,34.68 +2,1,27,32.4,4.5,5.4,1,3,2,11.79,1.15,2,2,1,2,2,1,20.67,1,0,31.005 +1,2,25,22.7,4.3,4.2,3,2,2,9.38,1.151,3,1,2,1,3,3,9.5,2,1,14.25 +2,1,29,30.2,4,4.3,1,2,1,8.65,1.153,2,3,1,2,2,1,14.5,3,0,21.75 +1,1,17,11.8,3.7,5,1,2,1,7.38,1.153,3,1,2,2,1,2,2.17,1,0,3.255 +1,2,26,43,3.7,4.6,3,3,1,6.73,1.154,2,1,1,2,3,1,7.17,2,1,10.755 +1,1,29,32.9,3.8,4,3,2,1,7.24,1.155,2,1,1,2,2,1,5.67,3,0,8.505 +2,1,32,36,4.2,5,3,2,1,6.39,1.157,2,1,1,2,2,2,14.03,2,0,21.045 +2,1,17,24.1,4.2,4.5,1,2,2,6.86,1.158,2,2,2,2,3,3,27.67,1,1,41.505 +1,1,35,17.4,3.8,4.1,1,2,1,5.34,1.159,2,1,2,1,3,2,30.83,3,0,46.245 +1,1,26,17.9,4,5.2,1,2,1,9.34,1.16,2,2,1,2,2,1,6.67,1,0,10.005 +1,2,25,24.1,4.2,3.8,2,2,1,4.15,1.16,1,1,2,1,3,2,18.1,1,1,27.15 +1,2,36,22.2,4.1,4.2,1,1,1,6.6,1.161,2,2,2,2,3,3,38.33,2,1,57.495 +1,1,23,19.6,3.7,4.5,1,2,1,4.66,1.162,1,1,2,1,3,2,10.67,3,0,16.005 +2,1,33,27.5,3.4,4,1,2,1,6,1.163,2,1,2,2,2,2,11.95,1,1,17.925 +2,2,29,44.5,4.5,4,1,2,1,6.14,1.163,1,1,3,1,3,3,18.73,1,0,28.095 +2,1,27,35.8,4.6,4,1,2,2,18.56,1.165,3,1,3,1,3,2,14.5,1,1,21.75 +2,1,21,31.1,4.8,3.7,1,2,1,5.45,1.166,1,1,2,2,1,2,6.92,1,0,10.38 +2,2,44,30.9,3.4,4,1,3,1,6.57,1.176,2,1,2,1,1,2,15.85,2,0,23.775 +1,2,27,20.8,4.5,4.3,1,2,1,4.75,1.179,2,1,4,2,1,2,17.87,2,1,26.805 +1,2,22,16,4.4,4,1,2,1,7.98,1.183,2,1,3,2,3,3,11.98,2,0,17.97 +1,2,32,23.6,3.5,4.2,1,2,2,8.58,1.185,2,1,1,2,3,3,14.38,2,1,21.57 +2,1,31,40.6,5,4.4,3,2,1,5.28,1.185,1,1,2,2,3,3,16.42,1,1,24.63 +1,1,21,18.4,4.1,4.5,1,2,1,6.26,1.186,2,3,2,2,3,2,15.42,1,0,23.13 +1,1,26,24.4,4.2,4.7,1,2,2,6.54,1.186,2,1,3,2,3,3,26.75,1,1,40.125 +2,1,23,30.9,4.1,4.5,1,2,2,5.45,1.187,2,1,2,2,3,2,18.5,3,0,27.75 +1,2,24,21.6,4.1,4,1,2,1,4.65,1.187,1,1,2,1,1,1,1.08,2,0,1.62 +1,1,24,22.4,4.2,4.1,1,2,1,5.94,1.188,2,1,3,1,3,2,17.58,1,1,26.37 +1,1,20,30.1,4.9,4.9,1,1,1,13.62,1.192,3,1,2,1,3,3,17.33,2,1,25.995 +1,1,40,22.2,4.5,4.3,3,2,1,5.33,1.193,2,1,2,1,3,3,12.8,1,0,19.2 +2,1,29,49.1,4.9,4.2,1,1,2,7.18,1.198,2,1,2,2,1,2,20.7,1,0,31.05 +1,2,34,20.8,4.2,4.7,1,1,1,4.36,1.205,2,1,2,1,1,2,4.33,1,1,6.495 +2,1,22,40.1,4.2,4.6,1,2,2,5.53,1.205,1,1,2,2,3,3,18.5,3,1,27.75 +1,1,18,14.1,4.4,4.5,1,2,1,5.13,1.207,2,2,2,2,2,1,6.17,1,0,9.255 +1,2,34,20.8,4.2,4,1,2,1,4.19,1.209,1,1,2,1,3,2,20.58,3,1,30.87 +2,2,25,20.8,4.1,4.6,3,2,1,6.81,1.21,2,1,2,2,3,2,28.77,3,0,43.155 +2,1,34,40.6,3.7,4.5,1,2,1,4.21,1.214,3,1,2,1,3,2,21.58,3,1,32.37 +1,1,26,16.4,4.2,4.5,3,2,2,7.21,1.221,2,1,1,2,3,1,35.67,3,1,53.505 +2,1,52,30.8,4.2,4.3,1,3,1,5.02,1.224,2,1,2,2,3,2,18.58,1,1,27.87 +1,2,20,21.2,5,4.7,1,1,1,4.75,1.227,2,1,1,2,1,1,4.5,2,1,6.75 +1,2,31,20.5,4.3,4.4,1,2,1,5.43,1.227,2,1,2,2,3,2,28.22,2,1,42.33 +1,1,30,26.7,3.7,4.1,1,1,2,4.73,1.227,2,2,3,2,3,3,38.23,1,1,57.345 +2,1,40,43,4,4.2,1,2,1,5.78,1.228,1,1,2,2,3,2,20.33,3,1,30.495 +1,1,36,16.5,4.2,3.6,2,2,1,5.14,1.235,2,2,1,2,2,2,22.67,2,1,34.005 +1,1,41,20.3,3.6,3.8,1,1,1,10.26,1.236,3,2,3,1,3,3,36.6,1,1,54.9 +1,1,32,18.7,3.9,5.2,3,2,2,5.76,1.238,2,1,3,2,3,3,20.67,1,1,31.005 +1,1,26,22,4.6,5,3,2,1,5.1,1.241,1,1,1,2,2,1,4.42,1,1,6.63 +2,1,25,41.1,4.4,5,3,3,2,7.63,1.241,2,1,3,2,3,3,12.93,1,0,19.395 +2,1,28,26.6,4.8,5.4,1,2,1,6.98,1.247,2,1,3,2,3,2,15.08,1,1,22.62 +1,1,22,13,4.1,4.5,1,2,1,8.86,1.249,1,1,1,1,2,1,8.83,2,0,13.245 +1,1,24,13.6,4.2,4.4,1,2,1,4.08,1.25,3,1,2,2,3,2,7.5,2,1,11.25 +1,1,27,26.4,4.3,4.4,2,2,1,5.58,1.252,2,1,1,2,1,1,5.68,1,0,8.52 +2,1,37,50.1,4.4,4.4,3,2,1,6.8,1.259,2,2,2,2,3,3,28.67,1,1,43.005 +1,1,27,15,3.5,4,3,2,1,5.52,1.263,3,1,3,1,3,3,24.67,2,1,37.005 +1,1,24,15.6,3.7,3.7,1,2,1,5.94,1.264,2,1,2,2,2,3,22.12,1,1,33.18 +2,2,33,36.4,4.8,5.5,1,2,1,5.19,1.264,1,1,2,2,3,2,16.7,3,0,25.05 +1,1,24,19.3,3.7,5.2,1,1,1,7.51,1.266,2,1,1,1,3,1,5.77,2,1,8.655 +1,1,33,19.7,4.5,4.7,1,1,1,6.04,1.266,3,2,2,2,3,3,20.65,1,1,30.975 +1,1,21,20.5,4.2,4.6,1,2,1,9.56,1.269,3,2,1,2,3,2,19.33,3,1,28.995 +1,2,25,28.3,4.2,4.2,1,1,1,5.22,1.275,1,1,1,1,3,1,11.67,1,1,17.505 +1,2,26,27.5,3.5,4.5,1,1,1,8.07,1.288,2,1,1,1,2,1,7.33,2,0,10.995 +1,2,20,17.9,5,4.5,1,1,1,5.33,1.304,1,1,2,2,2,3,23.33,2,1,34.995 +1,1,21,23.4,4.1,4.7,1,2,2,7.44,1.306,2,1,2,1,3,2,21.18,2,1,31.77 +2,1,26,30.4,3.3,4.2,3,2,2,6.22,1.309,2,1,3,2,3,3,15.67,2,1,23.505 +1,1,20,19.5,4.2,4.5,1,2,1,5.94,1.315,3,1,2,2,3,3,10.67,1,1,16.005 +1,1,20,19.8,4.2,5.5,1,2,2,4.98,1.316,2,1,3,2,3,3,17.7,2,0,26.55 +2,1,49,37.6,3.6,4.6,3,2,1,4.08,1.32,1,1,2,2,3,2,15.65,1,1,23.475 +2,2,27,44.3,4.2,4.8,3,2,2,7.14,1.324,2,1,3,2,3,3,22.18,1,1,33.27 +1,2,21,13,3.8,4.2,1,2,2,6.06,1.327,3,1,2,2,3,2,24.83,3,1,37.245 +2,1,28,32.6,4.2,4.6,1,1,1,7.09,1.334,3,1,3,1,3,3,39.08,2,0,58.62 +1,2,37,25.3,4,4.5,1,2,1,5.89,1.334,3,1,3,2,3,3,30.67,3,1,46.005 +1,1,31,12.8,3.5,4.3,1,2,1,9.59,1.339,1,2,3,2,3,3,34.98,1,0,52.47 +1,1,24,14.2,4.8,4.6,1,1,1,9.34,1.348,3,2,1,2,3,3,27.57,2,1,41.355 +1,1,46,29.9,3.5,3.8,1,1,1,5.87,1.348,1,1,2,2,3,2,34.83,3,1,52.245 +1,1,31,31.4,3.9,4.6,3,2,1,6.24,1.352,1,1,1,2,2,1,3.85,1,0,5.775 +1,1,34,13.3,4,4.3,1,1,2,6.72,1.361,2,1,1,2,3,2,11.95,2,1,17.925 +1,1,28,20.1,4.3,4.5,3,2,1,10.12,1.372,2,1,1,2,1,1,4.98,1,1,7.47 +1,1,26,18.9,4.5,4.4,1,1,1,4.63,1.387,1,1,2,2,3,2,29.05,3,0,43.575 +1,1,17,16.5,3.7,3.9,1,2,1,4.45,1.394,2,3,2,1,3,3,15.58,1,0,23.37 +1,1,24,14.3,4.5,3,1,2,1,4.19,1.41,2,1,2,1,3,2,14.83,3,0,22.245 +1,1,19,19.9,4.2,4,1,2,1,7.6,1.411,2,1,1,2,2,1,14.08,2,1,21.12 +1,2,26,12.1,3,5,3,2,1,6.24,1.42,3,1,3,2,3,3,7.67,1,1,11.505 +2,1,45,24.9,3.8,5.6,1,1,1,5.95,1.444,2,2,2,2,3,2,14.5,1,1,21.75 +1,2,40,22.5,4.1,4.7,1,1,2,5.68,1.446,3,1,3,2,3,3,35.08,3,0,52.62 +2,1,26,30.1,5.5,6,1,2,1,6.23,1.469,1,1,2,2,2,1,10.08,2,0,15.12 +1,1,21,21.7,4,4.3,1,2,1,5.59,1.475,2,1,1,2,2,1,9.9,2,0,14.85 +1,1,28,18.1,4.2,4,1,2,1,4.31,1.475,1,1,3,2,3,2,25.05,1,0,37.575 +1,1,26,17.8,4.4,4,1,2,1,7.22,1.502,2,1,3,2,1,2,7.77,2,0,11.655 +1,2,31,15.3,4.3,4.5,3,2,2,5.03,1.531,2,1,2,2,3,2,14.57,1,1,21.855 +1,1,24,21.8,3.5,4.5,1,1,1,4.9,1.582,2,1,1,1,2,1,4.93,2,0,7.395 +1,1,26,18.8,4.2,4.6,1,1,1,6.03,1.585,1,1,2,2,3,2,19.57,3,0,29.355 +1,1,28,22,4.2,4.1,1,2,1,5.99,1.61,2,3,2,2,3,3,22.7,1,1,34.05 +1,1,45,29.2,3.7,4.4,1,1,1,7.09,1.67,2,1,2,2,3,3,10.5,1,1,15.75 +1,1,33,15.2,4.7,3.9,1,1,1,6.64,1.706,2,1,4,1,2,1,5.77,1,0,8.655 +1,1,27,18.9,3.7,4.9,1,1,1,5.58,1.781,2,2,1,1,3,1,12.12,3,0,18.18 +1,2,21,17.8,4.3,3.8,1,2,1,7.63,1.913,2,1,1,2,3,2,22.38,1,1,33.57 +1,1,26,14.7,3.7,4,1,1,1,5.32,1.942,2,1,1,1,2,1,4.43,2,0,10.12 diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-20-Phase2A-前端集成与多步骤工作流开发总结.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-20-Phase2A-前端集成与多步骤工作流开发总结.md new file mode 100644 index 00000000..6f017859 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/2026-02-20-Phase2A-前端集成与多步骤工作流开发总结.md @@ -0,0 +1,187 @@ +# SSA-Pro 2026-02-20 开发总结:Phase 2A 前端集成与多步骤工作流 + +> **版本:** v1.0 +> **日期:** 2026-02-20 +> **编写:** AI 开发助手 +> **状态:** ✅ Phase 2A 前端集成完成 + Block-based 架构方案达成共识 + +--- + +## 1. 开发目标 + +本日核心目标是 **Phase 2A 智能核心前端集成**,将后端多步骤工作流(Workflow)功能端到端打通到前端 UI,并解决集成过程中发现的所有 Bug。 + +--- + +## 2. 完成工作清单 + +### 2.1 Python 数据质量服务集成 + +| 任务 | 状态 | 说明 | +|------|------|------| +| CSV 直传 Python 端点 | ✅ 完成 | 新增 `/api/ssa/data-profile-csv` 端点,Python 直接解析 CSV | +| 端口配置修复 | ✅ 修复 | 默认端口从 8081 修正为 8000 | +| 环境变量补充 | ✅ 完成 | `backend/.env` 新增 `EXTRACTION_SERVICE_URL` | +| 文件格式智能检测 | ✅ 完成 | `DataProfileService` 自动检测 JSON/CSV 调用不同端点 | + +### 2.2 多步骤工作流前端集成 + +| 任务 | 状态 | 说明 | +|------|------|------| +| 意图识别增强 | ✅ 完成 | `WorkflowPlannerService` 正则提取变量名 + 变量类型判断 | +| 统计工具智能选择 | ✅ 完成 | 根据变量类型(分类/连续)正确选择统计方法 | +| SSE 实时进度 | ✅ 完成 | 前后端 SSE 消息格式对齐,支持 `connected`/`step_start`/`step_complete` | +| 工作流执行触发 | ✅ 修复 | SSE 连接时自动触发 `executeWorkflow`,解决"卡在调用R引擎"问题 | + +### 2.3 前端 UI 修复与优化 + +| 任务 | 状态 | 说明 | +|------|------|------| +| SAP 按钮误显示 | ✅ 修复 | 移除数据上传后自动显示分析计划的逻辑 | +| 数据质量报告位置 | ✅ 修复 | 移至 `messages.map` 循环前,紧跟数据上传 | +| 分析计划 undefined | ✅ 修复 | 前后端 `WorkflowPlan` 接口对齐 | +| 复用 MVP 设计 | ✅ 完成 | 多步骤模式复用 MVP 单步骤的 UI 组件风格 | +| CSS 布局混乱 | ✅ 修复 | 移除 `position: absolute`,修复 padding 双重叠加 | +| 滚动不可用 | ✅ 修复 | 移除 `.terminal-box` 的 `max-width` 限制 | + +### 2.4 多步骤结果展示 + +| 任务 | 状态 | 说明 | +|------|------|------| +| 执行日志 | ✅ 完成 | MVP 风格 terminal-box + TraceLogItem,显示 `[步骤 N] 工具名 → 状态` | +| 统计结果渲染 | ✅ 完成 | 各步骤独立显示统计量、P值、效应量、置信区间 | +| 分组统计表 | ✅ 完成 | `group_stats` 渲染为 sci-table | +| 回归系数表 | ✅ 完成 | `coefficients` 渲染为专用表格 | +| 描述性统计 | ✅ 完成 | 专用 `DescriptiveResultView` 组件,处理 `variables`+`by_group` | +| 图表显示 | ✅ 完成 | `plots` base64 图片渲染 | + +### 2.5 导出功能 + +| 任务 | 状态 | 说明 | +|------|------|------| +| R 代码导出 | ✅ 完成 | 多步骤模式聚合所有步骤的 `reproducible_code` | +| Word 报告导出 | ✅ 完成 | `exportWorkflowReport` 完整报告:摘要+各步骤详情+描述性统计 | +| 描述性统计导出修复 | ✅ 修复 | 正确解析 `variables` 对象中数值/分类变量并生成表格 | + +### 2.6 架构讨论与规划 + +| 任务 | 状态 | 说明 | +|------|------|------| +| Block-based 协议评估 | ✅ 完成 | 评估并认可 `SSA-Pro 动态结果渲染与通信协议规范.md` | +| 开发计划编写 | ✅ 完成 | `08-Block-based动态结果渲染开发计划.md` | + +--- + +## 3. 关键 Bug 修复详情 + +### Bug #1:CSV 文件解析失败 + +**现象**:后端报错 `"Unexpected token 's', "sex,smoke,"... is not valid JSON"` +**根因**:`DataProfileService` 从 OSS 下载 CSV 文件后,尝试 `JSON.parse()` 解析 +**修复**:新增文件类型检测逻辑,CSV 文件直接以字符串形式发送到 Python 新端点 `/api/ssa/data-profile-csv` + +### Bug #2:统计方法选择错误 + +**现象**:分析 `sex` 与 `Yqol`(分类变量 0/1)的关系,推荐 T 检验而非卡方/Logistic +**根因**:`WorkflowPlannerService` 未提取具体变量名,也未检查变量类型 +**修复**:重写 `parseUserIntent` 和 `generateSteps`,基于正则提取变量名 + `DataProfile` 变量类型判断 + +### Bug #3:SSE 执行卡死 + +**现象**:前端显示"正在调用云端 R 引擎"但无进展,后端日志显示执行正常 +**根因**:SSE stream 路由仅注册监听器,未触发 `executeWorkflow` +**修复**:`/:workflowId/stream` 路由在客户端连接时异步触发工作流执行 + +### Bug #4:结果数据丢失 + +**现象**:执行日志正常但结果区域为空,导出按钮不显示 +**根因**:`WorkflowExecutorService` 只展开 `response.data.results`,丢失 `plots`、`reproducible_code` 等字段 +**修复**:将 `plots`、`result_table`、`reproducible_code`、`trace_log`、`warnings` 全部注入 `step.result` + +### Bug #5:描述性统计显示异常 + +**现象**:描述性统计只显示几个数字,不是表格 +**根因**:R 服务返回 `{ summary, variables: { "varName": {...} } }` 嵌套结构,前端未正确解析 +**修复**:创建 `DescriptiveResultView` 专用组件,正确遍历 `variables` 对象生成数值/分类变量表格 + +### Bug #6:Word 报告缺少描述性统计 + +**现象**:导出 Word 报告无描述性统计内容 +**根因**:`exportWorkflowReport` 未处理描述性统计的 `variables` 对象结构 +**修复**:添加 `classifyExportVar` 辅助函数,遍历 `variables` 生成 docx 表格行 + +--- + +## 4. 修改文件清单 + +### 后端 (backend) + +| 文件 | 修改类型 | 说明 | +|------|---------|------| +| `services/DataProfileService.ts` | 增强 | CSV 直传 Python、文件类型检测 | +| `services/WorkflowPlannerService.ts` | 重写 | 意图解析、变量类型判断、工具选择 | +| `services/WorkflowExecutorService.ts` | 修复 | result 字段完整传递 | +| `routes/workflow.routes.ts` | 修复 | SSE 触发执行 | +| `.env` | 新增 | `EXTRACTION_SERVICE_URL` | + +### Python 服务 (extraction_service) + +| 文件 | 修改类型 | 说明 | +|------|---------|------| +| `main.py` | 新增 | `/api/ssa/data-profile-csv` 端点 | + +### 前端 (frontend-v2) + +| 文件 | 修改类型 | 说明 | +|------|---------|------| +| `components/SSAChatPane.tsx` | 修复 | SAP 显示逻辑、DataProfile 位置、工作流调用 | +| `components/SSAWorkspacePane.tsx` | 重构 | 复用 MVP 设计、DescriptiveResultView、多步骤渲染 | +| `components/SSACodeModal.tsx` | 增强 | 多步骤 R 代码聚合 | +| `hooks/useWorkflow.ts` | 修复 | SSE 消息兼容处理 | +| `hooks/useAnalysis.ts` | 增强 | `exportWorkflowReport` + 描述性统计导出 | +| `types/index.ts` | 扩展 | SSE 消息类型定义 | +| `styles/ssa-workspace.css` | 修复 | 布局、滚动、间距 | + +--- + +## 5. 架构决策 + +### 达成共识:Block-based 动态结果渲染 + +经过讨论,团队就以下架构方向达成共识: + +1. **采用 Block-based 协议**:R 工具输出标准化的 `report_blocks` 数组 +2. **4 种 Block 类型**:`markdown`、`table`、`image`、`key_value` +3. **Node.js 零维护**:直接透传 `report_blocks`,不做字段映射 +4. **前端单一组件**:`DynamicReport.tsx` 动态渲染所有工具的结果 +5. **渐进式迁移**:新旧协议并存,逐步替换 + +详见 `04-开发计划/08-Block-based动态结果渲染开发计划.md` + +--- + +## 6. 当前状态总结 + +| 维度 | 状态 | +|------|------| +| Phase 2A 后端 | ✅ 100%(多步骤工作流、意图识别、SSE 进度) | +| Phase 2A 前端 | ✅ 100%(UI 集成、结果展示、导出功能) | +| 数据质量服务 | ✅ Python 服务正常调用 | +| 端到端流程 | ✅ 上传 → 质量报告 → 多步骤规划 → 执行 → 结果 → 导出 | +| Block-based 重构 | 📋 计划已制定,待开始 | + +--- + +## 7. 下一步计划 + +1. **Block-based 动态渲染实施**(~2.5 天) + - Phase 1:前端 `DynamicReport.tsx` + R 辅助函数库 + - Phase 2:7 个 R 工具逐一改造 + - Phase 3:清理旧的自定义渲染代码 + +2. **更多统计工具前端验证** + - 卡方检验、相关分析、回归分析的多步骤联合执行测试 + +--- + +**文档结束** diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro MVP 智能化增强指南.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro MVP 智能化增强指南.md new file mode 100644 index 00000000..2279e7bb --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro MVP 智能化增强指南.md @@ -0,0 +1,94 @@ +# **SSA-Pro MVP 智能化增强指南:基于理想愿景的资产提取** + +**文档版本:** v1.0 + +**创建日期:** 2026-02-20 + +**文档定位:** 作为《00-MVP开发计划总览》的高级补充包。 + +**核心主旨:** 摒弃“重型流程引擎”的代码包袱,全面吸收《理想状态与智能化愿景设计》中的“智能交互与决策”灵魂。 + +## **1\. 总体评估:愿景文档的闪光点在哪里?** + +《SSA-Pro 理想状态与智能化愿景设计》精准指出了传统统计软件的通病——**“逼迫用户做统计学决策”**。它提出系统应该具备:意图理解、数据诊断、决策表匹配、以及综合结论生成。 + +这四个要素**完全不需要复杂的底层引擎支持**,它们属于\*\*认知层(Cognitive Layer)\*\*的能力。我们完全可以把它们吸收进现有的 SSA-Planner (智能规划师) 和 SSA-Critic (结果解读) 模块中。 + +## **2\. 五大可落地的“愿景精华”与 MVP 补充方案** + +### **💡 精华一:从“选方法”到“翻译临床意图” (Clinical Intent Translation)** + +* **愿景痛点**:医生不会说“给我做个 T 检验”,只会说“这药对高血压有效吗”。 +* **MVP 现状**:现有的 RAG 检索过于依赖工具名(如用户没提到 T检验,可能检索不到)。 +* **如何低成本补入 MVP**: + * **行动**:在 MVP 的 Phase 1(大脑与咨询)中,强化 Query Rewriter (查询重写器) 的 Prompt。 + * **具体做法**:教 LLM 建立【临床黑话 \-\> 统计术语】的映射字典。 + * 遇到“有效/无效、优于、比...好” ![][image1] 映射为“差异分析、优效检验”。 + * 遇到“危险因素、风险、有没有关系” ![][image1] 映射为“相关性分析、回归分析”。 + * **价值**:代码 0 增加,仅靠调优 Prompt,就能让系统具备“听懂医生人话”的顶级智能感。 + +### **💡 精华二:用“决策表”替代“AI的瞎猜” (Decision Table Driven)** + +* **愿景痛点**:完全依赖大模型(LLM)去选方法,容易产生幻觉,选错工具。 +* **MVP 现状**:原计划由 LLM 阅读 10 个工具的描述,自己决定用哪个。 +* **如何低成本补入 MVP**: + * **行动**:在 Config Center (配置中台) 的 Excel 表格中,强制加入**决策要素树**。 + * **具体做法**:在导入的 tools\_library.xlsx 中,除了工具名字,增加三列硬性约束: + 1. **X 变量要求**(如:二分类) + 2. **Y 变量要求**(如:连续数值) + 3. **实验设计**(如:独立/配对) + * 在 PlannerService 生成方案时,不让 LLM 自由发挥,而是让 LLM 提取用户数据的 X/Y 特征,去**严格匹配**这个决策表。 + * **价值**:用最原始的“规则引擎”约束 AI,让方法选择的准确率达到 100%。 + +### **💡 精华三:用“场景宏工具”实现“一键全流程” (Macro-Tools for Scenarios)** + +* **愿景痛点**:用户想要的是“全部分析完”,而不是一个个跑工具。 +* **MVP 现状**:我们的底层是执行单节点 R 代码。 +* **如何低成本补入 MVP**: + * **行动**:**降维打击!绝对不要做 Node.js 的流程引擎。** 我们用 R 语言写“套餐脚本”。 + * **具体做法**:在 MVP 的 10 个工具中,保留 8 个单点工具(如 T检验、卡方),**额外新增 2 个“宏工具 (Macro-Tools)”**: + * ST\_MACRO\_RCT\_EFFICACY (临床试验疗效一键包:内部包含 Table1 画表 \+ 缺失值填补 \+ 主效应 T检验 \+ 结论汇总)。 + * 这个宏工具在系统看来,依然只是\*\*“一个 R 脚本”\*\*,但它内部执行了完整的流程。 + * **价值**:满足了理想愿景中“完整流程编排”的需求,却把长达 18 天的引擎开发周期,缩短为 R 工程师 1 天写一个长脚本的成本。 + +### **💡 精华四:前置的数据诊断与自适应 (Data Diagnosis & Adaptation)** + +* **愿景痛点**:数据格式不对,直接跑会报错。 +* **MVP 现状**:现有的“统计护栏”是在 R 执行时报错降级。 +* **如何低成本补入 MVP**: + * **行动**:在 Planner 阶段增加轻量级的“数据诊断警告”。 + * **具体做法**:在 Planner 读取到数据 Schema 后,如果发现用户想做 T 检验,但 Y 变量的类型是 String(可能包含了 "mmol/L" 等单位)。 + * Planner 在生成的 SAP 卡片上提前亮黄灯:*“警告:您的结局变量当前为文本型,系统将在执行前尝试自动提取数值。若提取失败可能报错。”* + * **价值**:将后置的报错提前到前置的“诊断”,给用户极强的安全感。 + +### **💡 精华五:论文级的结论生成 (Publication-Ready Interpretation)** + +* **愿景痛点**:系统只输出冷冰冰的 P 值,距离真正的报告还差最后一步。 +* **MVP 现状**:现有的 Critic 会解释 P 值。 +* **如何低成本补入 MVP**: + * **行动**:升级 Critic Agent 的输出模板。 + * **具体做法**:把经典的医学报告规范(如 CONSORT 规范、STROBE 规范关于统计方法的描述要求)注入到 Critic 的 System Prompt 中。 + * 强制要求 Critic 的输出结构包含: + 1. **方法学描述**(可直接复制到论文 Method 部分,如:"Continuous variables were expressed as mean ± SD...") + 2. **核心结论** + 3. **临床意义提示** + * **价值**:真正实现了愿景中提到的“输出可直接用于论文”,产品价值瞬间翻倍。 + +## **3\. 补充进 《00-MVP开发计划总览》 的具体 Action Items** + +为了将这些精华落地,建议在现有的 MVP 开发计划中补充以下任务(**不需要修改架构,也不增加过多工时**): + +| 原 MVP 阶段 | 需补充的任务项 (源自愿景) | 负责人 | 增加工时预估 | +| :---- | :---- | :---- | :---- | +| **Phase 1: 大脑与咨询** | **M1.4** Excel 配置表中增加 X/Y 变量类型与实验设计的“决策表”字段。 | 统计专家 | \+0.5 天 | +| **Phase 1: 大脑与咨询** | **B1.5** 优化 Planner Prompt,使其能把临床意图(如“对比疗效”)映射为统计术语。 | 后端开发 | \+1 天 | +| **Phase 2: 四肢与执行** | **R2.4** 在 10 个工具指标外,编写 1-2 个包含多步操作的 **“场景宏工具 (Macro R Script)”**。 | R 开发 | \+1.5 天 | +| **Phase 3: 合体与交付** | **B3.1** 优化 Critic Prompt,强制按学术期刊规范输出“方法学说明”和“综合报告”。 | 后端/专家 | \+0.5 天 | + +## **4\. 总结:给理想主义者的致敬** + +这套增强方案是对原愿景文档最好的回应: + +**“我们完全认同您提出的所有智能化用户体验(意图识别、智能诊断、流程包、论文级报告),并且我们找到了无需重建底层引擎,用极低成本在 MVP 阶段就能实现它们的方法。”** + +[image1]: \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro Prompt体系与专家配置边界梳理.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro Prompt体系与专家配置边界梳理.md new file mode 100644 index 00000000..711da9fe --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro Prompt体系与专家配置边界梳理.md @@ -0,0 +1,95 @@ +# **SSA-Pro Prompt 体系与专家配置边界梳理** + +**文档版本:** v1.0 + +**创建日期:** 2026-02-20 + +**核心目的:** 划定 AI 工程师(写 Prompt)与统计专家(写配置)的工作边界,明确系统各环节的控制权归属。 + +## **1\. 核心理念:骨架与血肉的分离** + +在 SSA-Pro 中,我们绝对不能让统计专家直接去维护一长串包含 \、{ "type": "json" } 等晦涩指令的原始 Prompt。这会让专家崩溃,也会导致 JSON 输出格式极易被改坏。 + +最佳实践是 **动态 Prompt 注入 (Dynamic Prompt Injection)**: + +* **AI 工程师(Prompt 工程师)**:负责维护 **Prompt 模板(骨架)**,控制 AI 的角色、思考链和严格的 JSON 输出结构。 +* **统计专家(业务专家)**:在配置中台中维护 **统计学规则(血肉)**。 +* **运行时**:Node.js 后端将专家的配置提取出来,像填空题一样塞进 Prompt 模板中发送给 LLM。 + +## **2\. 全链路 Prompt 梳理 (何处需要 Prompt?)** + +SSA-Pro 全流程共有 **3 个核心环节** 需要大模型介入,因此对应 **3 个核心 Prompt**: + +### **环节一:意图重写器 (Query Rewriter Prompt)** + +* **作用**:将医生口语(“看两个药的效果差异”)翻译成统计学黑话(“差异性分析,T检验”),用于向量库高精度检索。 +* **Prompt 骨架 (AI 工程师)**:规定必须输出 3-5 个同义词的 JSON 数组。 +* **专家配置注入 (配置中台)**:专家在后台配置 **“同义词/黑话字典”**。 +* **举例**:System Prompt \= "你是一个检索翻译官。请参考以下专家提供的字典:{{Expert\_Dictionary}},将用户输入翻译为标准术语。" + +### **环节二:智能规划师 (SSA-Planner Prompt) ⭐ 最核心** + +* **作用**:根据用户数据列名(Schema),从检索到的 Top-5 工具中挑出 1 个,并生成参数映射计划 (SAP)。 +* **Prompt 骨架 (AI 工程师)**: + * 设定角色:“你是一位顶尖的生物统计学家。” + * 强制要求:“你必须输出包含 tool\_code, reasoning, params 的 JSON,并先在 \ 标签中思考。” +* **专家配置注入 (配置中台)**: + * 注入检索到的工具定义:{{Tool\_Name}}, {{Usage\_Context\_Rules}}, {{Data\_Type\_Requirements}}。 + * **专家真正写的是**:“T检验要求因变量为连续数值,自变量为二分类。”这句话会被原封不动塞进 Prompt 里,指导 AI 决策。 + +### **环节三:结果解读审查员 (SSA-Critic Prompt)** + +* **作用**:将 R 跑出来的冰冷 JSON 结果(如 p\_value: 0.04),写成可以放在论文里的标准段落。 +* **Prompt 骨架 (AI 工程师)**:规定输出 Markdown 格式,规定必须包含“结论”、“统计量”、“P值”三个小节。 +* **专家配置注入 (配置中台)**: + * 注入 **“解释规范与禁用词”**(例如:专家在后台配置“严禁使用‘证明了’一词,必须用‘具有统计学意义’”)。 + * 注入 R 执行的真实结果 {{R\_Output\_JSON}}。 + +## **3\. 统计配置中台:到底要配置什么? (Config Center Inventory)** + +理清了 Prompt,我们再来看 **配置中台 (Admin)** 里,统计专家到底要填哪些东西。 + +配置中台不仅服务于 LLM (Prompt),还服务于底层的 R 引擎 (Executor)。 + +### **📊 专家需要配置的 5 大核心模块:** + +#### **A. 语义与决策配置 (供 Planner Prompt 消费)** + +1. **工具名称与描述**:例如 独立样本 T 检验,用于比较两组独立样本的均值差异。(用于 RAG 检索) +2. **决策要素表 (Decision Table)**: + * 专家勾选:X变量=分类,Y变量=连续数值。 + * *(后端会自动把这些勾选项翻译成自然语言,塞进 Planner Prompt 中当作判别规则)*。 + +#### **B. 话术与规范配置 (供 Critic Prompt 消费)** + +3. **解释模板与禁忌语**: + * 专家填写:当 P \< 0.05 时,话术模板为:{X} 对 {Y} 存在显著影响 (P={P\_value})。 + +#### **C. 严谨性配置 (直接供 R Executor 消费,与 Prompt 无关!)** + +4. **统计护栏规则 (Guardrails)**: + * 专家配置:必须执行 Shapiro-Wilk 检验,阈值 P \< 0.05 时,触发 Switch\_To\_Wilcoxon 动作。 + * **⚠️ 注意**:这些护栏规则 **绝对不要** 放到 Prompt 里让大模型去判断。这些规则是直接存为 JSON,发给 R Docker,由 R 代码强逻辑执行的。 + +#### **D. 资产生成配置 (直接供 R Executor 消费)** + +5. **R 代码片段模板 (Glue Template)**: + * 专家在后台贴入一段带 {{group\_var}} 占位符的 R 代码。这是用户最后下载的白盒代码。 + +## **4\. 总结:两者的黄金边界** + +为了方便团队理解,请记住这个表格: + +| 资产类型 | 谁来写? | 存在哪里? | 最终被谁执行/阅读? | +| :---- | :---- | :---- | :---- | +| **System Prompt 模板 (骨架)** | AI 工程师 / 后端 | 数据库 prompt\_templates 表 | 传给大模型 (DeepSeek) | +| **工具适用条件/数据类型要求** | 统计专家 | 配置中台 (Excel/Web) | 被塞进 Prompt,传给大模型 | +| **统计护栏 (如正态性 P\<0.05 降级)** | 统计专家 | 配置中台 (Excel/Web) | 传给 R 服务,**由 R 代码强执行** | +| **可复现的 R 代码模板** | 统计专家 | 配置中台 (Excel/Web) | 传给 R 服务,拼接后提供给用户下载 | +| **论文结论解释规范** | 统计专家 | 配置中台 (Excel/Web) | 被塞进 Critic Prompt,传给大模型 | + +### **💡 最终建议** + +**配置中台(哪怕 MVP 阶段只是一个 Excel 表),是承载统计专家智慧的唯一载体。** 专家只需要在这个 Excel 里用人话填表。 + +后端代码会负责把这个 Excel 解析拆分:一部分用来拼装 Prompt 让 AI 变聪明,另一部分以 JSON 形式发给 R 引擎让计算变严谨。 \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 动态结果渲染与通信协议规范.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 动态结果渲染与通信协议规范.md new file mode 100644 index 00000000..6ef70d6a --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 动态结果渲染与通信协议规范.md @@ -0,0 +1,172 @@ +# **SSA-Pro 动态结果渲染与通信协议规范** + +**文档版本:** v1.0 + +**创建日期:** 2026-02-20 + +**解决痛点:** 统一 100+ 种统计工具的输出格式,实现后端免维护、前端动态渲染。 + +**核心思想:** R 端输出的不是“数据”,而是“UI 渲染区块 (Blocks)”。 + +## **1\. 核心架构思想:区块化 (Block-based Architecture)** + +借鉴 Notion 和 Jupyter Notebook 的设计思想,我们将所有可能的统计输出抽象为**有限的几种“基础积木(Blocks)”**。 + +不论是 T 检验、生存分析还是复杂的回归模型,其输出结果最终都可以拆解为以下 4 种基础类型的组合: + +1. **text / markdown**: 用于 AI 解读、简单结论、警告提示。 +2. **table**: 用于三线表、矩阵、数据框。 +3. **image**: 用于所有的可视化图表。 +4. **key\_value**: 用于展示核心统计量(如 P 值、t 值等高亮卡片)。 + +## **2\. JSON 通信协议定义 (The Universal Protocol)** + +R 服务最终返回给 Node.js,Node.js 再原封不动透传给前端的 JSON 结构,**必须**是如下的标准协议: + +{ + "status": "success", + "trace\_log": \[ ...执行日志... \], + "reproducible\_code": "library(ggplot2)...", + + // ⭐ 核心变革:统一的 blocks 数组 + "report\_blocks": \[ + { + "type": "markdown", + "content": "\*\*AI 解读:\*\* 结果表明新药组的血压下降幅度显著大于对照组..." + }, + { + "type": "table", + "title": "Table 1\. 组间差异比较", + "data": { + "headers": \["Group", "N", "Median \[IQR\]", "P-Value"\], + "rows": \[ + \["Drug", "60", "14.5 \[12.1-16.8\]", "\< 0.001 \*\*"\], + \["Placebo", "60", "8.2 \[6.5-10.4\]", ""\] + \] + }, + "footer": "Note: IQR \= Interquartile Range; \*\* P \< 0.01" + }, + { + "type": "image", + "title": "Figure 1\. 血压下降值分布", + "format": "base64", + "src": "iVBORw0KGgoAAAANSUhEUgAAAAE...", // base64 字符串 + "caption": "箱线图展示了两组的分布情况" + }, + { + "type": "key\_value", + "title": "核心指标", + "items": \[ + {"label": "W Statistic", "value": "2845.5"}, + {"label": "Effect Size (r)", "value": "0.45", "status": "warning"} + \] + } + \] +} + +## **3\. R 端的开发规范 (如何吐出这个协议?)** + +R 工程师在封装 Wrapper 时,不需要关心前端怎么画图,只需要把结果打包成上述的 list。 + +**R 代码示例 (以 T 检验为例):** + +run\_tool \<- function(input) { + \# ... 执行计算 res \<- t.test(...) ... + \# ... 画图并转 base64 ... + + \# 统一打包为 Blocks + report\_blocks \<- list( + list( + type \= "markdown", + content \= sprintf("独立样本 T 检验结果显示,P值为 %.3f。", res$p.value) + ), + list( + type \= "table", + title \= "描述统计与检验结果", + data \= list( + headers \= c("统计量", "数值"), + rows \= list( + c("t 值", round(res$statistic, 2)), + c("自由度 df", round(res$parameter, 2)), + c("P 值", res$p.value) + ) + ) + ), + list( + type \= "image", + title \= "均值差异对比图", + format \= "base64", + src \= base64\_image\_string + ) + ) + + return(list( + status \= "success", + trace\_log \= logs, + report\_blocks \= report\_blocks, \# 直接返回 Blocks 数组 + reproducible\_code \= code\_str + )) +} + +## **4\. 前端的动态渲染策略 (Dynamic Renderer)** + +前端彻底解放。**前端不需要写 TTestResult.tsx 或 AnovaResult.tsx,只需要写一个 DynamicReport.tsx。** + +前端只需遍历 report\_blocks 数组,根据 type 挂载对应的基础组件即可。 + +**React 伪代码:** + +// 1\. 准备基础积木组件 +const MarkdownBlock \= ({ content }) \=\> \{content}\; +const SciTableBlock \= ({ title, data, footer }) \=\> ( + \ + \{title}\ + \ + \\{data.headers.map(h \=\> \{h}\)}\\ + \{data.rows.map(row \=\> \{row.map(cell \=\> \{cell}\)}\)}\ + \ + {footer && \{footer}\} + \ +); +const ImageBlock \= ({ title, src, caption }) \=\> ( + \ + \{title}\ + \{title} + \{caption}\ + \ +); + +// 2\. 核心动态渲染器 +export const DynamicReport \= ({ blocks }) \=\> { + return ( + \
+ {blocks.map((block, index) \=\> { + switch (block.type) { + case 'markdown': + return \; + case 'table': + return \; + case 'image': + return \; + case 'key\_value': + return \; + default: + return \
未知的区块类型: {block.type}\; + } + })} + \ + ); +}; + +## **5\. Node.js 的角色 (Zero-Maintenance)** + +通过这套协议,Node.js 后端变成了绝对的 **“零维护 (Zero-Maintenance)”** 状态。 + +如果未来 R 团队新增了第 101 个工具(比如一个极度复杂的神经网络模型,返回 5 张表、10 张图),Node.js 的代码 **一行都不需要改**!因为它只负责把 R 返回的 JSON 原样抛给 React。 + +## **6\. 总结:多表多图的终极解法** + +* **问:如何展示多表多图?** +* **答:R 脚本往 report\_blocks 数组里不断 push 即可。想展示几张就 push 几个 image block。** + +这种“协议化、区块化”的设计,是现代 SaaS 平台(如飞书、Notion、Jupyter)的基石架构。它赋予了 R 团队极大的排版自由度,同时彻底保护了前端和后端的架构稳定性。 \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进路径评估报告.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进路径评估报告.md new file mode 100644 index 00000000..4aae8a4b --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进路径评估报告.md @@ -0,0 +1,68 @@ +# **SSA-Pro 智能化演进路径评估:Tool Calling vs Agentic Code Gen** + +**评估目标:** 对比「工具调用范式 (Tool Calling)」与「靶向动态代码生成范式 (Agentic Code Gen)」在临床科研场景下的真实用户价值差异。 **核心结论:** 对于 90% 的标准临床科研需求,工具调用范式能在“合规性”和“可复现性”上提供绝对保障,是 MVP 的不二之选。而引入“错误反馈与动态自愈”的代码生成范式,是应对极度复杂/脏数据的未来终极形态(V2.0 目标)。 + +## **1\. 概念对齐与场景设定** + +* **路线 A(Tool Calling / 专家静态工具库模式)**:系统内置 100+ 经过严格验证的 R 脚本(含宏脚本)。LLM 负责“听懂人话、提取列名、排兵布阵(串联 5 个工具)、填入静态的 JSON 参数”,R 引擎负责“傻瓜式执行”。**代码本体绝对不允许被修改。** +* **路线 B(Agentic Code Gen / 靶向动态自愈模式 \- 研发团队的终极设想)**:系统把 100+ 脚本作为基座。LLM 负责“理解意图、串联工具”。如果直接运行报错,系统会将**错误日志 (Error Log) 和数据片段**反馈给 LLM,LLM 像真实的程序员一样,**针对性地修改这 100 个工具中的代码细节或参数**(例如:自动补写一段剔除极端值的 R 代码),反复试错,直到顺利跑出结果。 + +## **2\. 核心命题解答:用户的感知差异大吗?** + +**结论:在“成功跑通”的情况下,用户感知差异不大;但在“遇到异常数据”时,路线 B 展现出极强的韧性,而路线 A 具有极高的合规安全性。** + +### **2.1 临床医生的“第一性原理”需求是什么?** + +医生使用统计软件(无论是 SPSS 还是未来的 AI 工具)的终极目的只有三个: + +1. **拿到经得起审稿人推敲的 P 值。** +2. **拿到可以直接贴进 SCI 论文的图表(三线表、KM曲线、森林图)。** +3. **不用自己去记复杂的统计学前提条件(如方差齐性)。** + +### **2.2 场景模拟:“我的数据里有几个病人的血压值录入成了‘未知’,帮我做个 T 检验”** + +* **如果是路线 A (组合工具)**:LLM 生成了 JSON。R 引擎调用固定脚本,读到“未知”这个字符串时,严格报错。系统提示用户:“您的数据存在非法字符,请清洗后再试”。(体验略有挫折,但绝对严谨)。 +* **如果是路线 B (靶向自愈代码)**:LLM 生成了代码并执行 \-\> R 引擎报错 Error: non-numeric argument \-\> LLM 拿到报错,立刻反应过来,**动态修改代码**,加上一句 df \<- df\[df$血压 \!= '未知', \] \-\> 再次执行 \-\> 成功出结果。(体验极佳,AI 自动擦屁股)。 + +**【用户感知与隐患对比】** + +* **体验上**:路线 B 像一个真正的高级助理,帮医生把脏活累活干完了。 +* **隐患上**:路线 B 悄悄剔除了样本,如果这不符合医学上的“意向性分析 (ITT) 原则”,擅自删数据会构成学术不端。而路线 A 强制报错,逼迫医生自己决定怎么处理缺失值,保住了学术红线。 + +## **3\. 系统性对比矩阵 (Systematic Evaluation)** + +基于团队对路线 B 的深刻理解,我们从商业产品必须考虑的五个维度进行深度对比: + +| 评估维度 | 路线 A:Tool Calling (当前计划) | 路线 B:Agentic Code Gen (团队终极愿景) | 谁更满足医疗场景? | +| :---- | :---- | :---- | :---- | +| **结果的权威性与合规度** | **极高**。底层是统计算法专家千锤百炼的代码,P 值绝对可靠。 | **中等**。大模型动态改代码,可能为了“让程序不报错”而采用错误的统计妥协(如暴力删数据)。 | 🏆 **路线 A 胜** (医疗是容错率为0的行业) | +| **可复现性 (Reproducibility)** | **100%**。昨天跑和今天跑,调用的工具和底层代码绝对一致。 | **较低**。LLM 的纠错路径可能每次不同,导致生成的修正代码不一致,P 值微调。 | 🏆 **路线 A 胜** (科研最讲究可复现) | +| **异常自愈与韧性 (Self-healing)** | **较弱**。遇到预设之外的脏数据,只能中断并报错给用户。 | **极强**。LLM 能够根据 Error Log 靶向修改代码,自动克服各种奇怪的运行时错误。 | 🏆 **路线 B 胜** (极其惊艳的技术能力) | +| **审计与排错成本** | **极低**。如果是由于工具 Bug,明确指向 ST\_T\_TEST 的某一行。 | **极高**。动态生成的孤品代码,报错后难以定位是 LLM 改错了还是数据有问题。 | 🏆 **路线 A 胜** | +| **响应延迟 (Latency)** | **极快** (毫秒级推理 JSON \+ 单次运行即出结果)。 | **很慢** (如果陷入报错-\>修代码-\>再跑的循环,可能需要数十秒甚至数分钟)。 | 🏆 **路线 A 胜** | + +## **4\. 路线 A 能满足大部分需求吗?** + +**能,能满足 90% 以上的标准临床科研需求。** + +医学统计是一门**高度标准化、八股文化**的学科。全球顶尖的医学期刊(如 Lancet, JAMA)对统计方法的报告都有严格的规范(如 CONSORT 声明)。 + +* 基线总是 Table 1。 +* 疗效对比总是 T 检验/卡方/非参数。 +* 风险因素总是 Logistic 回归或 Cox 回归。 +* 生存分析总是 KM 曲线加 Log-rank。 + +这 100+ 个工具,本质上就是医学统计学的“元素周期表”。只要 LLM 的“智商”足够高,能准确地在元素周期表里挑选出正确的元素并排列组合(比如:清洗 \-\> PSM匹配 \-\> Table1 \-\> Cox回归),就足以应对绝大多数医生的日常科研。 + +## **5\. 结论与团队沟通建议** + +不要觉得“做工具调用”就是低级。团队设想的“基于错误反馈靶向修改代码”是非常成熟且高级的 Agentic 思维,这绝对是数据科学 AI 的未来。 + +**但真正的医疗产品价值,不仅在于底层技术有多“炫技”,更在于给用户的交付物有多“靠谱”。** \#\#\# 对团队的最终融合建议: + +1. **MVP 阶段(现在)**:死磕 **路线 A (Tool Calling)**。把 100 个工具的入参、出参、护栏打磨到极致。让 Planner (LLM) 变成一个最聪明的“全科主任医师”,精准地给患者(数据)开出正确的“检查单(工具组合)”。 +2. **V2.0 阶段(未来的路线 B 融合)**:采用\*\*“受限的自愈生成”\*\*。 + * **在数据清洗(Data Wrangling)环节**:允许使用路线 B。当数据格式不对时,让 LLM 根据 Error Log 动态生成 R 代码去清洗数据。 + * **在核心统计检验(Core Stats)环节**:依然死守路线 A。绝对禁止 LLM 动态修改计算 P 值的核心函数逻辑。 + +您可以告诉团队:**“排列组合 5 个高级工具,对用户来说魔法感已经拉满;而把你们设想的‘动态纠错修改’能力,克制地用在外围的数据清洗上,这是我们在【极客技术】与【医疗严谨性】之间找到的最完美的平衡点。”** \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进阶梯.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进阶梯.md new file mode 100644 index 00000000..e80f63f3 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 智能化演进阶梯.md @@ -0,0 +1,58 @@ +# **SSA-Pro 智能化演进阶梯:从工具调用到代码智能的必由之路** + +**核心观点:** Phase 2(工具调用与编排)不仅是 MVP 的交付目标,更是通往 Phase 3(动态代码自愈与生成)**不可逾越的绝对基础**。没有 Phase 2 的基建,Phase 3 就是空中楼阁。 + +## **一、 为什么 Phase 2 是 Phase 3 的地基?** + +如果你想让大模型(LLM)在 Phase 3 能够“看到报错 \-\> 动态修改 R 代码 \-\> 重新执行”,你必须在 Phase 2 提前把以下 **三大基础设施** 彻底跑通。而这三大设施,只有在“固定工具调用”的模式下,才能最低成本地搭建出来: + +### **1\. 稳如泰山的“执行沙箱与错误捕获管道” (The Execution Sandbox)** + +* **在 Phase 3 中**:LLM 需要根据 Error Log 来修代码。 +* **Phase 2 要填的坑**:R 容器报错时,Node.js 能不能精准捕获到 stderr?能不能把冗长的 R 报错提炼成 LLM 能看懂的精简 JSON?如果沙箱崩溃了,能不能一秒钟重启? +* **结论**:如果我们在 Phase 2 连**固定代码**的报错抓取和网络通信都没玩明白,直接上**动态代码**,一旦卡死,你连是 Docker 挂了还是 LLM 写了死循环都分不清。 + +### **2\. LLM 的“路由与编排智商” (Orchestration Intelligence)** + +* **在 Phase 3 中**:LLM 需要自己构思数据处理的完整逻辑链。 +* **Phase 2 要填的坑**:我们先用 100 个固定工具来“考试”。面对用户的复杂需求,LLM 能不能正确地挑出 \[缺失值填补\] \-\> \[PSM 匹配\] \-\> \[T检验\] 这 3 个工具,并把顺序排对?参数能不能传对? +* **结论**:如果 LLM 连现成的 100 个积木都拼不对,你指望它直接凭空捏造(写代码)出一个完美的城堡?先在 Phase 2 把 LLM 的 **“流程编排能力 (Planning)”** 训练到 100% 准确,是进入 Phase 3 的及格线。 + +### **3\. 建立“黄金数据集” (Golden Dataset for Fine-tuning)** + +* **在 Phase 3 中**:LLM 需要以这 100 个专家脚本为“知识库”进行学习和微调。 +* **Phase 2 要填的坑**:在 Phase 2 真实上线后,我们会收集到成千上万次医生真实调用的日志。我们知道了“在什么样的数据集下,调用什么样的工具,搭配什么样的参数,最终成功跑出了结果”。 +* **结论**:这些 Phase 2 沉淀下来的成功调用记录,就是未来训练我们自己 **专属医学代码大模型 (Medical Coder LLM)** 无价的“黄金数据集”。没有 Phase 2 的数据投喂,Phase 3 的模型就是“没有临床经验的医学生”。 + +## **二、 SSA-Pro 演进路线图 (The Crawl-Walk-Run Strategy)** + +理清了基础之后,我们团队的路线图就变得极其清晰、极具战斗力,并且前后逻辑完美自洽: + +### **🏃‍♂️ 第一阶段:爬行期 (Phase 1/2) \- 当前 MVP 目标** + +* **核心动作**:将 100 个 R 脚本封装为标准 API(原子工具 \+ 宏工具)。 +* **AI 角色**:**高级接线员 / 调度枢纽 (Dispatcher)**。 +* **机制**:LLM 纯靠 Prompt 识别意图 \-\> 填入 JSON 参数 \-\> 触发固定工具执行。 +* **商业价值**:快速上线,证明产品逻辑,用 100% 正确的统计结果获取第一批种子医生的信任。 + +### **🚶‍♂️ 第二阶段:行走期 (Phase 2.5) \- 探索性边界突破** + +* **核心动作**:引入\*\*“受限的自愈生成”\*\*(就是之前我建议的过渡方案)。 +* **AI 角色**:**数据清洗实习生 (Data Wrangler)**。 +* **机制**:核心的统计检验(跑 P 值)依然强制调用那 100 个死工具。但是,如果医生上传的数据格式很奇葩,允许 LLM **动态生成一段数据清洗的 R 代码 (dplyr)**,跑通后再喂给核心工具。 +* **商业价值**:系统开始具备处理非标脏数据的能力,韧性大幅增强。 + +### **🏃‍♂️ 第三阶段:奔跑期 (Phase 3\) \- 团队的终极 Agent 愿景** + +* **核心动作**:全面拥抱 **Self-healing Agentic Workflow (自愈型智能体工作流)**。 +* **AI 角色**:**全能数据科学家 (AI Data Scientist)**。 +* **机制**:LLM 把那 100 个脚本吸收入向量知识库。用户下达复杂指令,LLM 组合脚本 \-\> 动态修改内部代码逻辑 \-\> 在安全沙箱执行 \-\> 遇到错误 \-\> 提取 Error Log \-\> 结合数据自动重写代码 \-\> 直到跑通并输出报告。 +* **商业价值**:成为真正的“统计学超级大脑”,技术壁垒深不可测,彻底甩开市面上的套壳竞品。 + +## **三、 结语:给团队的强心剂** + +你的这句反问:*“换句话说,Phase 2 是 Phase 3的基础,我们得先把调用工具玩明白,把调用工具顺序弄清楚,后面才是 Phase 3动态修改代码来改进,对吗?”* + +这句话就是你们团队从\*\*“理想主义的极客”**蜕变为**“兼具极客精神与工程手腕的顶尖团队”\*\*的标志! + +饭要一口一口吃,路要一步一步走。把 Phase 2 这个地基打得坚如磐石,你们梦寐以求的 Phase 3 终极智能体,自然会水到渠成!现在,请全军出击,拿下 Phase 2!🚀 \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 架构审查反馈与智能化路径讨论.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 架构审查反馈与智能化路径讨论.md new file mode 100644 index 00000000..7c4a365e --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/SSA-Pro 架构审查反馈与智能化路径讨论.md @@ -0,0 +1,544 @@ +# SSA-Pro 架构审查反馈与智能化路径讨论 + +**文档类型:** 审查反馈与讨论纪要 + +**反馈对象:** +- 《架构审查报告:SSA-Pro 愿景与落地策略》 +- 《SSA-Pro MVP 智能化增强指南》 + +**反馈日期:** 2026-02-20 + +**核心立场:** 🟢 认可务实策略的价值,🔴 但必须纠正一个根本性的理解偏差 + +--- + +## 🔴 关键澄清:100个 R 代码的真正用途 + +> **这是整个讨论中最重要的一点,必须首先澄清。** + +### 审查报告的理解(工具调用范式) + +``` +100个 R 脚本 = 100个"工具" + +系统工作方式: +用户提问 → LLM 选择工具 → 填入参数 → 调用执行 → 返回结果 + +本质:这是一个"高级版的 SPSS 菜单",只是用 AI 帮你选菜单项 +``` + +### 我们真正想要的(代码智能范式) + +``` +100个 R 脚本 = 知识库 / 参考模板 / 可学习的范例 + +系统工作方式: +用户提问 → LLM 理解意图 → LLM 诊断数据特征 + → LLM 从知识库检索相关代码 + → LLM 根据数据特征 **动态修改/组合/生成** 代码 + → 执行生成的代码 → LLM 解读结果 + → 根据结果决定下一步 → 继续修改/生成代码... + +本质:这是一个"AI 统计学家 + AI 程序员",能够针对用户的具体情况定制分析 +``` + +### 两种范式的本质区别 + +| 维度 | 工具调用范式 | 代码智能范式 | +|------|-------------|-------------| +| **R 代码角色** | 被调用的固定函数 | 被学习的知识库 | +| **LLM 角色** | 参数填充器 | **代码理解者 + 改写者** | +| **适应性** | 只能处理参数化场景 | 能处理任意数据形态 | +| **灵活性** | 固定的输入输出格式 | 动态适应用户需求 | +| **智能化程度** | Level 2(智能工具箱) | **Level 3(智能助手)** | + +### 具体场景对比 + +``` +场景:用户有3组数据,样本量不等,想比较差异 + +工具调用范式: +──────────────── +系统:查表 → 3组 → 调用 ANOVA 工具 → 传参数 → 执行 +问题:如果数据不满足方差齐性呢?工具没有处理逻辑! +结果:要么报错,要么输出错误结果 + +代码智能范式: +──────────────── +LLM 思考: + 1. 3组比较 → 参考知识库中的 anova.R 模板 + 2. 用户说样本量不等 → 需要先检验方差齐性 + 3. 从知识库找到 levene_test.R 的代码片段 + 4. 组合代码:先做 Levene 检验 + 5. 根据 Levene 结果: + - 方差齐 → 使用标准 ANOVA + - 方差不齐 → 修改代码使用 Welch's ANOVA + 6. 生成完整的、适应用户数据的 R 代码 + 7. 执行 → 解读结果 → 决定是否需要事后检验 + 8. 如果需要 → 继续生成事后检验代码... +结果:完整、正确、适应用户数据的分析流程 +``` + +### 产品定位确认 + +> **我们的目标是 Level 3:颠覆性的智能分析助手** +> +> 不是做一个"比 SPSS 好用一点"的工具, +> 而是要做一个"让不懂统计的医生也能完成专业分析"的 AI 助手。 +> +> **这是我们讨论所有技术方案的前提。** + +--- + +## 一、前言:我们讨论的核心目标 + +在深入讨论技术方案之前,我想先明确一个根本性问题: + +> **SSA-Pro 的核心价值是什么?** + +答案应该是:**让不懂统计的医生,能够完成专业级的统计分析。** + +这意味着系统需要具备: +1. **理解能力** —— 听懂医生的"人话" +2. **判断能力** —— 自动选择正确的方法 +3. **规划能力** —— 动态规划分析路径 +4. **执行能力** —— 可靠地完成计算 +5. **表达能力** —— 输出可用于论文的结论 + +如果我们的方案只解决了 1、2、4、5,但丧失了 **3(规划能力)**,那么我们做的就是一个"高级自动化工具",而不是"智能分析助手"。 + +**这是本次讨论的核心关切。** + +--- + +## 二、对审查文档的逐项反馈 + +### 2.1 完全认可的观点 ✅ + +| # | 审查观点 | 我的评价 | 认可理由 | +|---|---------|---------|---------| +| 1 | "流程执行引擎"的技术复杂度被低估 | ✅ 完全认可 | 中间数据传输、错误回滚、断点续传确实是工程难题 | +| 2 | MVP 阶段不应陷入"引擎陷阱" | ✅ 完全认可 | MVP 的首要目标是快速交付可感知价值 | +| 3 | 精华一:临床意图翻译 | ✅ 完全认可 | 成本极低(改 Prompt),价值极高 | +| 4 | 精华二:决策表驱动 | ✅ 完全认可 | 这正是愿景中强调的"四维匹配" | +| 5 | 精华四:前置数据诊断 | ✅ 完全认可 | 把后置报错变成前置预警,体验极佳 | +| 6 | 精华五:论文级结论生成 | ✅ 完全认可 | 升级 Critic Prompt,直接提升产品价值 | + +**小结:** 这些建议都是"低成本、高价值"的优化,可以立即采纳。 + +--- + +### 2.2 部分认可,但有保留的观点 🟡 + +#### 观点:"用宏工具完全替代流程引擎" + +**我的评价:** 🟡 **部分认可** + +**认可的部分:** +- 宏工具确实是一种务实的降维方案 +- 在 MVP 阶段可以快速交付"一键分析"的体验 +- 性能优越(一次 R 进程内完成,无 IO 损耗) + +**保留的部分:** + +| 问题 | 说明 | +|------|------| +| **宏工具是"固定套餐"** | 用户的数据千变万化,预设的流程不可能覆盖所有情况 | +| **丧失"动态规划"能力** | 这是愿景中最核心的"智能"体现 | +| **用户无法干预中间步骤** | 愿景强调"人机协同",宏工具做不到 | + +**具体案例说明:** + +``` +宏工具能处理的场景(理想情况): +──────────────────────────────── +用户:"分析这批临床试验数据" +系统:→ 调用 ST_MACRO_RCT_PIPELINE + → 执行固定流程:Table1 → 缺失值填补 → T检验 → 森林图 + → 输出完整报告 +✅ 完美! + +宏工具处理不了的场景(现实情况): +──────────────────────────────── +用户:"分析这批数据,但我的分组有3组,而且有协变量需要调整" +系统:→ 调用 ST_MACRO_RCT_PIPELINE + → 宏工具内部写死的是 T检验(适用于2组) + → 无法动态切换为 ANOVA(适用于3组) + → 无法自动添加协变量调整 +❌ 失败,或输出错误结果! +``` + +**这正是"自动化"与"智能化"的本质区别:** + +| 维度 | 自动化(宏工具) | 智能化(动态规划) | +|------|-----------------|-------------------| +| 流程 | 预先固定 | 根据数据动态生成 | +| 方法 | 脚本编写时确定 | 执行时根据数据特征选择 | +| 用户干预 | 不支持 | 每一步可确认/修改/跳过 | +| 适应性 | 只能处理"标准情况" | 能处理各种边缘情况 | + +--- + +### 2.3 不认可的观点 🔴 + +#### 观点:"宏工具 = 实现智能化愿景" + +**我的评价:** 🔴 **不认可** + +**原因:** + +愿景文档中描述的核心智能是"**分析路径规划师(Pathway Planner)**": + +> "系统像一位经验丰富的统计学家,根据您的研究目标和数据特征,自动规划最优的分析路径。" + +这要求系统能够: +1. **动态生成**分析流程(而非使用预设模板) +2. **根据数据特征**调整每一步的方法 +3. **让用户确认**每一步后再执行 + +而宏工具的本质是: +1. **预先编写好**的固定流程 +2. **脚本内部硬编码**的方法选择 +3. **一次性执行完毕**,用户无法干预 + +**打个比方:** + +| 比喻 | 宏工具 | 动态规划 | +|------|--------|---------| +| 餐厅 | 套餐(A套餐、B套餐) | 自助餐 + 厨师现做 | +| 旅行 | 跟团游(固定行程) | 私人定制 + 导游随时调整 | +| 分析 | 固定模板报告 | AI 根据数据定制分析路径 | + +**如果我们只做宏工具,用户会说:"这和 SPSS 的批处理有什么区别?"** + +--- + +## 三、对"智能化"的重新定义与分级 + +为了让讨论更加具体,我建议将"智能化"分为三个层级: + +### 3.1 智能化分级模型 + +``` +Level 3: 真·智能分析助手(愿景终态) +┌─────────────────────────────────────────────────────────────┐ +│ 动态规划 + 分步执行 + 人机协同 + 自适应调整 │ +│ 用户:"分析这批数据" → 系统规划5步 → 每步确认 → 执行 │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ +Level 2: 智能工具箱(审查建议) +┌─────────────────────────────────────────────────────────────┐ +│ 意图理解 + 方法选择 + 宏工具套餐 + 论文输出 │ +│ 用户:"分析这批数据" → 系统选套餐 → 一键执行 → 输出报告 │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ +Level 1: 自动化工具(传统软件) +┌─────────────────────────────────────────────────────────────┐ +│ 用户选方法 + 填参数 + 点执行 + 看结果 │ +│ 用户:"我要做T检验" → 填参数 → 执行 → 看P值 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 各方案对应的智能化层级 + +| 方案 | 智能化层级 | 与竞品差距 | +|------|-----------|-----------| +| 当前 MVP(已完成部分) | Level 1.5 | 略优于 SPSS | +| 审查建议(宏工具方案) | Level 2 | 明显优于 SPSS | +| 愿景设计(动态规划) | Level 3 | 颠覆性领先 | + +### 3.3 我的核心问题 + +> **我们要做 Level 2 还是 Level 3?** +> +> - 如果目标是 Level 2,审查建议完全足够 +> - 如果目标是 Level 3,审查建议是过渡方案,不是终态 + +--- + +## 四、重新思考:基于代码智能范式的实现路径 + +> **注意:原有的"宏工具"方案基于工具调用范式,与我们的目标不兼容。以下是基于代码智能范式的新思路。** + +### 4.1 核心能力建设 + +要实现代码智能范式,需要构建以下核心能力: + +| 能力 | 描述 | 技术方案 | +|------|------|---------| +| **代码知识库** | 100+ R 代码模板,结构化存储 | RAG + 向量检索 | +| **代码理解** | LLM 理解 R 代码的功能和参数 | 代码注释 + Few-shot | +| **代码生成** | LLM 根据需求修改/组合代码 | Prompt Engineering | +| **代码执行** | 安全执行 LLM 生成的代码 | R 沙箱环境 | +| **结果解读** | LLM 解读统计结果 | 专业 Prompt | + +### 4.2 建议的验证路径 + +在全面开发之前,建议先做 **Proof of Concept(概念验证)**: + +``` +POC 目标:验证 LLM 能否根据数据特征,从知识库检索并修改 R 代码 + +POC 场景: +───────── +输入: + - 用户问题:"比较两组患者的疗效差异" + - 数据特征:两组、连续变量、样本量各 50、非正态分布 + +期望输出: + - LLM 从知识库检索 t_test.R 和 wilcoxon.R + - LLM 判断:非正态 → 选择 wilcoxon + - LLM 修改代码:填入正确的变量名 + - 生成可执行的 R 脚本 + - 执行并返回结果 + +验证标准: + - 方法选择正确率 > 90% + - 生成代码可执行率 > 95% + - 结果解读准确率 > 90% +``` + +### 4.3 分阶段实现建议 + +| 阶段 | 目标 | 核心能力 | 时间 | +|------|------|---------|------| +| **POC** | 验证技术可行性 | 单步代码生成 + 执行 | 1 周 | +| **MVP** | 基础智能分析 | 多步规划 + 代码生成 | 2-3 周 | +| **V1.1** | 增强人机协同 | 分步确认 + 修改建议 | 2 周 | +| **V2.0** | 完整智能助手 | 自动纠错 + 多轮对话 | 3-4 周 | + +### 4.4 可保留的审查建议 + +虽然"宏工具"方案不适用,但以下建议与范式无关,可以保留: + +| 建议 | 价值 | 是否采纳 | +|------|------|---------| +| 精华一:临床意图翻译 | 提升意图理解 | ✅ 采纳 | +| 精华四:前置数据诊断 | 提升用户体验 | ✅ 采纳 | +| 精华五:论文级结论生成 | 提升输出价值 | ✅ 采纳 | +| 精华二:决策表驱动 | 🟡 需改造 | 改为"知识库元数据" | +| 精华三:宏工具 | ❌ 与目标不兼容 | 不采纳 | + +--- + +## 五、需要讨论的关键问题 + +在继续开发之前,我希望团队能够明确回答以下问题: + +### 5.1 产品定位(已确认) + +> **✅ 产品定位已明确:Level 3 —— 颠覆性的智能分析助手** + +| 问题 | 我们的答案 | +|------|-----------| +| SSA-Pro 的目标是什么? | **Level 3:颠覆性的智能助手** | +| 我们与竞品的差异化在哪里? | **AI 动态规划分析路径 + 动态生成代码** | +| 用户愿意为什么付费? | **解决"不会统计"的根本问题** | +| 100个 R 代码的用途? | **知识库,供 LLM 学习和改写,不是固定工具** | + +### 5.2 技术路径问题 + +| 问题 | 我的建议 | +|------|---------| +| MVP 是否采用宏工具方案? | ✅ 采用(作为快速交付手段) | +| 宏工具是否是终态? | ❌ 不是(后续需要演进到轻量级编排) | +| 何时开始 V1.1 的设计? | 建议 MVP 发布后立即开始 | + +### 5.3 资源投入问题 + +| 问题 | 需要团队确认 | +|------|-------------| +| R 工程师是否有能力编写宏工具脚本? | 需要确认 | +| 前端是否有余力预埋分步展示 UI? | 需要确认 | +| MVP 的交付时间是否有压力? | 需要确认 | + +--- + +## 六、总结与行动建议 + +### 6.1 我的核心观点 + +1. **审查建议存在根本性的理解偏差** —— 他们理解的是"工具调用"范式,而我们要做的是"代码智能"范式 +2. **100个 R 代码是"知识库",不是"工具箱"** —— LLM 要能理解、修改、组合这些代码 +3. **智能化的核心是"动态生成"** —— LLM 根据数据特征动态修改代码,而不是调用固定工具 +4. **宏工具方案与我们的目标不兼容** —— 它锁死了系统在 Level 2,无法演进到 Level 3 +5. **我们需要重新设计技术架构** —— 基于"代码智能"范式,而非"工具调用"范式 + +### 6.2 建议的行动 + +| 行动 | 负责人 | 优先级 | +|------|--------|--------| +| **统一团队对"代码智能"范式的理解** | 全体 | 🔴 最高 | +| 重新设计系统架构(基于代码智能范式) | 架构师 | 🔴 最高 | +| 评估 LLM 代码生成/修改能力的可行性 | AI 工程师 | 🟡 高 | +| 采纳审查建议中与范式无关的精华(意图翻译、论文输出) | 开发团队 | 🟢 中 | + +### 6.3 一句话总结 + +> **审查建议基于"工具调用"范式,而我们要做的是"代码智能"范式。** +> +> **我们的目标是:LLM 理解 R 代码知识库,根据用户数据动态修改/生成代码,实现真正的智能分析。** +> +> **不着急开发,但要把路想清楚。智能化是核心,否则这次开发就没有意义。** + +--- + +## 七、代码智能范式的核心架构 + +既然我们明确了要做"代码智能"范式,那么系统架构需要重新设计: + +### 7.1 核心架构图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SSA-Pro 代码智能架构 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────────────────────────┐ │ +│ │ 用户自然语言 │───────▶│ 意图理解层 (LLM) │ │ +│ │ "比较两组疗效" │ │ • 解析研究目的 │ │ +│ └─────────────────┘ │ • 识别分析类型 │ │ +│ │ • 提取关键变量 │ │ +│ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────────────────────────┐ │ +│ │ 用户数据 │───────▶│ 数据诊断层 (LLM + R) │ │ +│ │ (CSV/Excel) │ │ • 变量类型识别 │ │ +│ └─────────────────┘ │ • 数据分布检测 │ │ +│ │ • 缺失值/异常值诊断 │ │ +│ │ • 统计前提检验 │ │ +│ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 路径规划层 (LLM) │ │ +│ │ │ │ +│ │ 输入:意图 + 数据特征 │ │ +│ │ 输出:分析路径(多步骤) │ │ +│ │ │ │ +│ │ 步骤1: 描述性统计 │ │ +│ │ 步骤2: 正态性检验 │ │ +│ │ 步骤3: 方差齐性检验 │ │ +│ │ 步骤4: T检验 或 秩和检验(动态) │ │ +│ │ 步骤5: 效应量计算 │ │ +│ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌─────────────────────────────────────┐ │ +│ │ R 代码知识库 │◀──────│ 代码生成层 (LLM) │ │ +│ │ (100+ 模板) │ 检索 │ │ │ +│ │ │───────▶│ • 从知识库检索相关代码 │ │ +│ │ • t_test.R │ │ • 根据数据特征修改代码 │ │ +│ │ • anova.R │ │ • 组合多个代码片段 │ │ +│ │ • wilcoxon.R │ │ • 生成完整可执行 R 脚本 │ │ +│ │ • ... │ │ │ │ +│ └─────────────────┘ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 执行层 (R Runtime) │ │ +│ │ │ │ +│ │ 执行 LLM 生成的代码 │ │ +│ │ 返回结果(数值 + 图表) │ │ +│ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 结果解读层 (LLM) │ │ +│ │ │ │ +│ │ • 解读统计结果 │ │ +│ │ • 判断是否需要下一步分析 │ │ +│ │ • 生成论文级结论 │ │ +│ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 循环判断 │ │ +│ │ │ │ +│ │ 需要继续?──是──▶ 返回代码生成层 │ │ +│ │ │ │ │ +│ │ 否 │ │ +│ │ ▼ │ │ +│ │ 输出完整报告 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 与"工具调用"范式的关键区别 + +| 环节 | 工具调用范式 | 代码智能范式 | +|------|-------------|-------------| +| **方法选择** | 从预设列表选择 | LLM 根据数据特征决定 | +| **参数填充** | 固定参数模板 | LLM 动态生成参数 | +| **代码执行** | 调用固定函数 | 执行 LLM 生成的代码 | +| **异常处理** | 预设的分支逻辑 | LLM 实时判断并调整 | +| **流程控制** | 线性或预设 DAG | LLM 动态决定下一步 | + +### 7.3 技术可行性评估(需讨论) + +| 能力 | 当前 LLM 水平 | 风险 | 缓解措施 | +|------|-------------|------|---------| +| 理解 R 代码 | ✅ 很强 | 低 | - | +| 修改 R 代码 | ✅ 较强 | 中 | 代码审查 + 沙箱执行 | +| 生成正确 R 代码 | 🟡 中等 | 中高 | 知识库约束 + 结果校验 | +| 解读统计结果 | ✅ 很强 | 低 | - | +| 动态决策下一步 | ✅ 较强 | 中 | 明确的决策规则 | + +### 7.4 需要解决的关键技术问题 + +1. **代码知识库的组织方式** —— 如何让 LLM 高效检索相关代码? +2. **代码生成的准确性** —— 如何确保生成的代码是正确的? +3. **执行安全性** —— 如何在沙箱中安全执行 LLM 生成的代码? +4. **错误恢复** —— 如果生成的代码执行失败,如何让 LLM 自动修复? +5. **人机协同点** —— 在哪些环节需要用户确认? + +--- + +## 八、附录:智能化能力达成对照表 + +| 智能化能力 | 愿景要求 | 工具调用范式 | 代码智能范式 | +|-----------|---------|-------------|-------------| +| 理解医生意图 | ✅ | ✅ 可达成 | ✅ 可达成 | +| 自动选择方法 | ✅ | 🟡 有限(预设列表) | ✅ 动态决策 | +| 动态规划流程 | ✅ | ❌ 固定流程 | ✅ **LLM 动态规划** | +| 适应数据特征 | ✅ | ❌ 固定参数 | ✅ **LLM 修改代码** | +| 分步展示过程 | ✅ | ❌ 一次输出 | ✅ 可分步展示 | +| 用户可干预步骤 | ✅ | ❌ 不支持 | ✅ 支持 | +| 论文级输出 | ✅ | ✅ 可达成 | ✅ 可达成 | + +**工具调用范式达成率:40%(3/7 核心能力)** + +**代码智能范式达成率:100%(7/7 核心能力)** + +--- + +## 九、结语 + +本文档的核心目的是**统一团队对 SSA-Pro 智能化方向的理解**。 + +### 我们必须达成的共识: + +1. **产品定位**:Level 3 颠覆性智能助手,不是 Level 2 智能工具箱 +2. **技术范式**:代码智能范式,不是工具调用范式 +3. **R 代码定位**:知识库 / 参考模板,不是固定工具 +4. **LLM 角色**:代码理解者 + 改写者 + 决策者,不是参数填充器 +5. **核心能力**:动态规划 + 动态生成,不是固定套餐 + +### 下一步讨论议题: + +1. 代码智能范式的技术可行性验证(Proof of Concept) +2. R 代码知识库的组织与检索方案 +3. LLM 代码生成的准确性保障机制 +4. 人机协同的交互设计 + +--- + +*文档结束* + +*不着急开发,先把路想清楚。智能化是核心,否则这次开发就没有意义。* + +*期待与团队的深入讨论!* diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:Phase 2A 核心开发计划 .md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:Phase 2A 核心开发计划 .md new file mode 100644 index 00000000..dabccbf7 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:Phase 2A 核心开发计划 .md @@ -0,0 +1,76 @@ +# **架构审查报告:Phase 2A 智能化核心开发计划** + +**审查对象:** 《07-Phase2A-智能化核心开发计划.md》 + +**审查时间:** 2026-02-20 + +**审查结论:** 🌟 **极度卓越 (S级计划)**。战略聚焦精准,架构切分合理。准予作为下阶段最高优先级执行,但需注意防范三大工程暗礁。 + +## **一、 架构师的高度赞同 (What You Did Exceptionally Well)** + +### **1\. 极其明智的“断舍离” (Postponing the Config Center)** + +把“配置中台”和“100个工具的量产”延后,是整个计划最亮眼的一笔。 + +在核心编排逻辑(Orchestration)跑通之前,去开发配置中台的 UI 是纯粹的浪费。在 Phase 2A 阶段,**“配置即代码 (Configuration as Code)”** 是最佳实践。直接用 TypeScript/JSON 文件来硬编码这 7 个工具的配置,速度最快,修改成本最低。 + +### **2\. 五大组件的精妙解耦 (The 5-Component Agentic Pipeline)** + +你们将原本耦合的 Planner 拆分成了 Data Profiler \-\> Intent Parser \-\> Tool Router \-\> Plan Generator \-\> Result Synthesizer。 + +这是教科书级别的 **大模型工作流 (LLM Workflow) 设计**!大模型在处理单一、确定性任务时幻觉极低。将一个庞大的 Prompt 拆分成 5 个职能单一的微型 Prompt,是保证系统每次都能输出稳定 SAP 计划的唯一解。 + +### **3\. 完美的“7 剑客”工具选型 (The Golden 7 Tools)** + +选出的 7 个工具(基线表、正态、T检验、秩和、卡方、相关、线回)不是随便挑的,它们恰好构成了一个完整的\*\*“临床基线分析 \+ 单因素推断”\*\*的业务闭环。如果能把这 7 个串联好,已经能解决医学研究生 60% 的毕业论文统计需求。 + +## **二、 工程暗礁与避坑预警 (Critical Warnings & Gotchas)** + +计划虽好,但在具体写代码时,这 5 大组件和多工具串联极容易在以下三个地方“翻车”: + +### **🚨 暗礁 1:Result Synthesizer 的“上下文爆炸” (Context Window Blowup)** + +* **计划描述**:将多个工具的执行结果收集起来,交给 Result Synthesizer (LLM) 生成综合结论。 +* **潜在灾难**:R 语言跑出来的原始 JSON 可能极大。比如做线性回归,如果把几百个残差值(Residuals)或者巨大的离群值数组都打包成 JSON 发给大模型,会导致 LLM Token 超载,响应极慢甚至直接报错。 +* **架构强制要求**: + 在封装这 7 个 R 工具时,**必须严格限制 R 脚本的输出规模**。R 脚本返回的 JSON 只能包含:P值、统计量 (t/F/chi-sq)、置信区间、核心系数 和 前端渲染图表所需的精简坐标点。**绝对禁止返回包含原始全量数据的长数组。** + +### **🚨 暗礁 2:Data Profiler 的“Node.js 内存刺客” (The Node.js OOM Trap)** + +* **计划描述**:Data Profiler 需要提取数据特征(如均值、缺失率)。 +* **潜在灾难**:如果由后端的 Node.js 去遍历解析 50MB 的 CSV 来算均值和缺失率,Node.js 极易发生 OOM(内存溢出)导致容器崩溃。 +* **架构强制要求 (已修正为 Python 方案)**: + 不要在 Node.js 里做重度数据探测!**强烈建议复用团队现有的“工具C (Python 智能数据清洗模块)”** 来充当 Data Profiler 的物理执行层。 + 工作流应该是:用户上传数据 \-\> Node.js 调起 **Tool C 的 Python 微服务** 执行高并发的数据探测 \-\> 快速返回各列的数据类型、缺失率、枚举值分类 \-\> 将这个轻量级的 Schema JSON 喂给 LLM 规划师。 + *(注:Tool C 的 Pandas/Polars 在处理数十 MB CSV 的 I/O 和基础统计上,性能远超 R,且完全复用了团队已有的异步架构与性能优化资产,完美实现了“Python 主内搞数据,R 主外做统计”的分工。)* + +### **🚨 暗礁 3:多工具编排中的“半路崩盘” (Partial Failure Handling)** + +* **计划描述**:Plan Generator 制定好顺序(如:正态检验 \-\> T检验),然后依次执行。 +* **潜在灾难**:如果第一步“基线表”跑成功了,第二步“T检验”因为某个极端数据报错了,整个流程是全部崩溃,还是能保留基线表的结果? +* **架构强制要求**: + 在设计流程执行器(Executor)时,必须采用 **“容错管道 (Fault-Tolerant Pipeline)”**。 + 任何一个工具报错,不应该导致整个 Workspace 崩溃。系统应该能在右侧 UI 渲染出: + ✅ 基线表 (执行成功, 点击查看) + ❌ T检验 (执行失败: 方差为0, 点击查看原因) + 让用户依然能拿到部分成果,这才是顶级商业软件的体验。 + +## **三、 对 Phase 2A 代码落地的一点补充建议** + +为了配合 V11 双屏前端原型的“魔法效果”,建议在后端的串联逻辑中加入 **“Server-Sent Events (SSE) 状态推送”**: + +当后端正在顺序执行这 7 个工具时,不要让前端傻等 10 秒钟。后端执行完一个工具,就通过 SSE 往前端推一个状态: + +1. {"status": "running", "step": "ST\_TABLE1", "msg": "正在绘制基线特征表..."} +2. {"status": "running", "step": "ST\_NORMALITY", "msg": "正在执行 Shapiro-Wilk 正态性检验..."} +3. {"status": "completed", "final\_report": {...}} + +前端接收到这些状态后,就能在 V11 原型的 ExecutionViewer(那个黑色的终端日志框)里一行行打出逼真的执行日志,**这不仅安抚了用户的等待焦虑,更把系统的“专业AI感”直接拉满**。 + +## **四、 总结** + +你们的 Phase 2A 计划是一份直指问题核心的“作战地图”。 + +去掉了“配表”的枯燥,直面“AI 编排”的挑战,并且聪明地联动了已有的 Python 资产。只要在数据探测和结果回传上做好**数据量的裁剪(卡死 Token 上限)**,这个 Phase 2A 交付后,SSA-Pro 将真正在技术上具备叫板主流数据分析 AI Agent 的底气。 + +**同意按此计划全面打响 Phase 2A 战役!祝团队旗开得胜!** \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:SSA-Pro 愿景与落地策略.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:SSA-Pro 愿景与落地策略.md new file mode 100644 index 00000000..54ac3997 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/架构审查报告:SSA-Pro 愿景与落地策略.md @@ -0,0 +1,80 @@ +# **架构委员会独立审查报告:SSA-Pro 智能化愿景与落地策略** + +**审查对象:** 《SSA-Pro 理想状态与智能化愿景设计》、《愿景与开发计划对比分析》 + +**审查时间:** 2026-02-20 + +**核心裁决:** 🟢 愿景极度认可,🔴 反对开发计划的推翻重来。建议采用“宏工具(Macro-Tool)降维打击”策略。 + +## **1\. 核心审查结论 (Executive Summary)** + +1. **愿景评估 (Vision)**:提出“医生要的是完整流程,而不是单个统计工具”的洞察**极其精准**,这是 SSA-Pro 未来能在市场上降维打击竞品的**核心壁垒**。 +2. **差距评估 (Gap)**:文档中指出的现有 MVP 计划与理想状态的差距是客观存在的(单节点执行 vs 多节点编排)。 +3. **落地路径评估 (Execution) \- 🚨 警告**:文档建议“新增 Phase 1.5,耗时 12-18 天开发流程执行引擎”,**我作为架构师坚决反对**。在底层原子工具(T检验、卡方等)尚未经受生产环境大规模数据考验时,去构建一个复杂的 DAG(有向无环图)流程编排引擎,会导致项目陷入无底洞,MVP 交付将遥遥无期。 + +## **2\. 为什么坚决反对现在做“流程执行引擎”?(架构风险剖析)** + +团队文档中低估了“多方法流程编排 (Workflow Orchestration)”的技术复杂度: + +1. **状态爆炸与数据流转 (Data Pipeline)**: + * 如果 Node.js 作为流程引擎,执行 A(数据清洗) \-\> B(特征筛选) \-\> C(T检验)。这意味着 R 容器执行完 A 后,要将几百 MB 的中间态数据回传给 Node/OSS,Node 再传给步骤 B的 R 容器。这会带来巨大的 IO 延迟和序列化灾难。 +2. **错误处理灾难 (Error Recovery)**: + * 流程走到第 4 步报错了,怎么回滚?UI 上怎么展示?用户修改参数后,是从第 4 步断点续传,还是从头重跑? +3. **前端 UI 极度复杂化**: + * 我们刚刚通过 V11 稳定了双屏 UI,如果引入流程节点,右侧面板需要变成类似 ComfyUI 或 Coze 的节点连线界面,工作量是按“月”计算的。 + +## **3\. 破局之道:以“大工具 (Macro-Tool)”降维打击** + +我们既要满足用户的终极愿景(一键生成论文级完整报告),又要保住现有的工程架构(单方法执行)。 + +**解决思路:将“流程编排的复杂度”从 Node.js / 前端下沉到 R 代码内部。** + +不要在 Node.js 中编排流程,而是定义一批 **“复合型宏工具 (Macro-Tools)”**。 + +### **举个例子对比:** + +* **愿景文档的思路(沉重的引擎)**: + Node.js 引擎调度 \-\> 调用基础基线表工具 \-\> 等待 \-\> 调用缺失值填补工具 \-\> 等待 \-\> 调用T检验工具 \-\> 等待 \-\> 调用多因素回归工具 \-\> 合并输出。 +* **架构师建议的思路(轻量级宏工具)**: + 1. 在配置中台中,除了配置基础的“T检验 (ST\_T\_TEST)”,新增一个超级工具叫 **“RCT 临床试验全流程标准分析 (ST\_MACRO\_RCT\_PIPELINE)”**。 + 2. 这个超级工具对应一段长达 200 行的 R 脚本 (rct\_pipeline.R)。 + 3. 这段 R 脚本在**一次容器运行中**,顺序执行:画 Table 1 \-\> 数据清洗 \-\> 分组比较 \-\> 敏感性分析 \-\> 统一输出为 Markdown 或一个巨大的 JSON。 + 4. Node.js 和前端**完全不需要改架构**,在它们眼里,这依然只是一次普通的“工具调用”。 + +### **这种方案的巨大红利:** + +| 维度 | 构建通用流程引擎 (不推荐) | 构建 R 宏工具脚本 (推荐) | +| :---- | :---- | :---- | +| **开发工时** | 3 \- 4 周 (前后端全改) | **2 \- 3 天** (写几段 R 聚合脚本即可) | +| **性能损耗** | 极高 (不断的容器启停和 IO) | **极低** (一次 R 进程内全内存计算) | +| **智能理解** | LLM 需要输出复杂的 DAG JSON | LLM 只需要路由到该“宏工具” | +| **用户体验** | 看到复杂的节点执行 | 点击执行后,一键输出终极报告 (符合预期) | + +## **4\. 对现有开发计划的微调建议 (Action Items)** + +**结论:不要推翻原有的 《00-MVP开发计划总览.md》,原计划完全有效,只需做以下三点微调:** + +### **调整 1:扩展“配置中台”的概念 (Phase 1\)** + +在开发基础统计工具(T检验、卡方等)的同时,由数据分析师/R工程师编写 3-4 个\*\*“场景化宏脚本”\*\*。例如: + +* ST\_SCENARIO\_SURVIVAL (生存分析标准全流程:KM曲线 \+ Log-rank \+ 单因素Cox \+ 多因素Cox) +* ST\_SCENARIO\_CLINICAL\_TRIAL (临床试验疗效评估标准流程:Table1 \+ 主效应检验 \+ 亚组森林图) + +### **调整 2:增强 Planner 智能体的路由能力 (Phase 2\)** + +修改 Planner (大模型) 的 System Prompt: + +"当用户的问题非常具体且单一(如:比较这两列的均值),请推荐基础统计工具(如 ST\_T\_TEST)。当用户的问题是一个宏大的研究目标(如:看看新药有没有效、帮我分析这批高血压数据),请直接推荐场景化宏工具(如 ST\_SCENARIO\_CLINICAL\_TRIAL)。" + +### **调整 3:UI 展示层的包容性 (与 V11 完美契合)** + +V11 的双屏 UI 已经完美支持这种宏大结果的展示。宏工具执行完毕后,返回的是一个包含了多个表格和多张图表的复杂 JSON。右侧工作区利用 ResultViewer 顺序渲染这些组件即可,这正是双屏设计大显身手的地方。 + +## **5\. 架构师致团队的总结寄语** + +向提出智能化愿景的团队成员致敬!你们看到了星辰大海。 + +但软件工程的艺术在于\*\*“用最简单的结构解决最复杂的问题”**。我们通过**把复杂的流程固化为 R 脚本模板(宏工具)\*\*,成功地避免了造一个沉重的 Node.js 工作流引擎的陷阱。 + +**无需推翻重来!请团队按照原定 MVP 计划继续冲刺,只需在 R 代码库中增加几个“全家桶套餐”级别的脚本,我们就能在 MVP 阶段交付你们渴望的“智能化愿景”!** \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/终极架构共识与智能化演进备忘录 (1).md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/终极架构共识与智能化演进备忘录 (1).md new file mode 100644 index 00000000..0945c03e --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/终极架构共识与智能化演进备忘录 (1).md @@ -0,0 +1,140 @@ +# **SSA-Pro 终极架构共识与智能化演进备忘录** + +**文档性质:** 架构争论复盘与终极演进路线图 + +**审查对象:** 团队反馈文档《SSA-Pro 架构审查反馈与智能化路径讨论》 + +**审查时间:** 2026-02-20 + +**核心定调:** 🟢 完全认同团队的终极愿景;🔴 对“动态执行生成代码”的安全与稳健性提出最高级别警告;🤝 提出“受限代码生成”作为完美桥梁。 + +## **一、 架构师的“认错”与全面致敬 (What I Completely Agree With)** + +首先,我要向团队致敬。你们指出的核心分歧点——**“高级版的 SPSS 菜单(Tool Calling)” vs “真正的 AI 数据分析师(Code Intelligence)”**,一针见血。 + +1. **认同“静态参数的局限性”**: + 你们是对的。临床数据极其肮脏和复杂(需要衍生变量、按条件过滤、合并清洗)。如果只依赖简单的 JSON 参数映射,一旦用户说“帮我把年龄大于 60 岁的人挑出来,再把血压分为高低两组,然后做个生存分析”,基于 Tool Calling 的架构会瞬间瘫痪,因为它无法在参数里表达这种动态的数据操作。 +2. **认同“100 个 R 脚本的真实定位”**: + 将这 100 个脚本视为\*\*“可学习的范例库(Knowledge Base / Code Templates)”\*\*,而不是死板的 API 工具,这是一个认知上的巨大飞跃。这让系统的天花板从“自动化”提升到了“智能化”。 +3. **认同“三阶段演进策略”**: + 你们提出的 Phase 1 (工具调用兜底) \-\> Phase 2 (宏脚本执行) \-\> Phase 3 (代码智能) 是极其成熟的务实主义。我们不仅没有分歧,反而达成了高度的战略共识。 + +## **二、 架构师的“红线”警告 (Where I Strongly Push Back / Warn)** + +作为对系统稳定性负责的架构师,针对你们心心念念的 **“Phase 3: Code Intelligence (执行 LLM 动态生成的代码)”**,我必须亮出工程红线。 + +这是整个业界(包括 OpenAI)都在面临的最难的骨头,切不可低估其危险性。 + +### **🚨 风险 1:动态代码执行的“核弹级”安全漏洞 (RCE Vulnerability)** + +* **团队设想**:LLM 生成 R 代码 \-\> 在沙箱中执行。 +* **残酷现实**:如果在常规的 Docker 容器中直接使用 R 的 eval(parse(text=llm\_code)) 或者将生成的代码写入文件运行,哪怕你做了所谓的文件权限限制,**这本质上依然是一个合法的 RCE (远程代码执行) 后门**。 +* **后果**:大模型极易被“提示词注入(Prompt Injection)”攻击,生成类似于窃取环境变量、发起内网 DDoS 或占用全部 CPU 资源的恶意代码。 +* **架构底线**:在没有引入 **Firecracker 级别的微虚拟机(MicroVM)** 或严格的 **eBPF 系统调用拦截**之前,**绝对禁止**在生产环境中执行 LLM 自由发挥生成的 R 代码。 + +### **🚨 风险 2:统计学的“幻觉谬误”与合规灾难** + +* **团队设想**:LLM 可以根据数据特征动态修改/组合统计代码。 +* **残酷现实**:LLM 在写 Python 的 pandas 时很强,但在写深度的医学统计 R 代码时,非常容易产生“看似合理实则谬误”的幻觉(比如错误地设置了随机效应项,或者用错了极大似然估计方法)。 +* **后果**:系统生成了 P 值,用户信了,发了论文,最后发现统计算法是错的。在医疗领域,这是致命的声誉打击。 +* **架构底线**:涉及 P 值计算的核心统计引擎(如 wilcox.test 的参数配置),必须保留经过人类专家验证的“硬护栏”,绝不能任由大模型自由发挥。 + +## **三、 破局方案:“受限代码生成”范式 (The Architect's Bridge)** + +为了兼顾你们想要的“灵活性(处理复杂数据操作)”和我要的“安全性(护栏与可审计)”,我提出 **“受限代码生成 (Constrained Code Generation)”** 架构。这是连接 Phase 1 和 Phase 3 的完美桥梁。 + +### **核心思想:把代码切分为“安全区”和“高危区”** + +我们将用户的分析诉求分为两截:**【数据加工 (Data Wrangling)】** 和 **【核心检验 (Core Stats)】**。 + +#### **1\. 安全区:数据加工 (允许大模型自由发挥)** + +* **场景**:过滤、重命名、衍生变量(如算 BMI)。 +* **机制**:允许 LLM 基于 dplyr 生成 R 代码(这部分代码就算有幻觉,最多就是报错,不会产生错误的 P 值)。 +* **安全措施**:使用 R 的 AST (抽象语法树) 解析器,在执行前扫描这段生成的代码,**白名单制**仅允许 mutate, filter, select, group\_by 等数据操作函数,发现任何可疑函数直接阻断。 + +#### **2\. 高危区:核心检验 (大模型仅能“填空”)** + +* **场景**:跑 T 检验、画 KM 曲线。 +* **机制**:LLM **不被允许**生成核心检验代码。它必须从你们那 100 个脚本库中,把“专家写好的模板”检索出来,然后把第一步处理好的数据输入进去。 + +### **💡 “受限代码智能”工作流示例** + +1. **用户输入**:“把年龄大于 60 岁的人挑出来,算一下男女的血压差异。” +2. **LLM 理解与检索**: + * 检索到专家模板:ST\_T\_TEST\_IND.R。 +3. **LLM 生成混合计划 (Hybrid Plan)**: + { + "data\_wrangling\_code": "df\_filtered \<- df %\>% filter(Age \> 60)", + "core\_tool": "ST\_T\_TEST\_IND", + "core\_params": { "group\_col": "Gender", "val\_col": "BloodPressure" } + } + +4. **Executor 执行**: + * 在沙箱内,先行安全校验并执行 data\_wrangling\_code。 + * 将清洗后的 df\_filtered 送入经过专家配置、带有强护栏的 ST\_T\_TEST\_IND 工具中执行。 + +**为什么这个方案好?** + +它给了大模型处理复杂现实数据的\*\*“手脚”(数据清洗代码生成)**,但牢牢锁住了大模型的**“嘴巴”(核心统计检验的绝对严谨性)\*\*。 + +## **四、 针对 MVP 阶段的具体行动建议 (Action Items for NOW)** + +既然我们对“三阶段演进”达成了共识,那么在眼下的 MVP 阶段,请务必执行以下微调: + +### **1\. 改变 Prompt 的“世界观”** + +在构建 Planner 的 Prompt 时,不要对 LLM 说“你是一个从列表里挑工具的接线员”,而是对它说: + +"你是一个顶尖的数据科学家。你现在有一个包含 100 个高级统计算法的**专家代码库**。请理解用户意图,从代码库中找出最合适的代码模板,并制定数据传入策略。" + +*(即便它现在只能输出 JSON 参数,这种世界观设定也会极大地提高它的推理质量。)* + +### **2\. 构建向量库时,保留“代码片段”** + +在专家填写 Excel 配置表时,不仅仅存入“工具名”和“适用场景”,把那 100 个 R 脚本的\*\*核心源代码片段(Core Snippet)\*\*也存入向量库。 + +在 RAG 检索时,把这些代码片段丢给 LLM。这为未来平滑过渡到 Phase 3 埋下伏笔。 + +### **3\. 先不碰动态代码执行** + +MVP 阶段(乃至整个 Phase 2),请严格克制住“让 LLM 动态写代码并执行”的冲动。用我们确定的“混合模式(智能分析 \+ 宏脚本)”打透核心业务流,验证 PMF(产品市场契合度)。 + +## **五、 最终建议** + +作为系统架构师,我必须为您详细拆解为什么在现阶段(MVP及打透核心业务流阶段)必须**严格克制**这种冲动: + +### **1\. 安全层面的“达摩克利斯之剑”(RCE 风险)** + +让 LLM 动态写代码并执行,本质上是在您的服务器上开了一个 **RCE(远程代码执行)** 的后门。 + +* 即使我们有那 100 个安全的脚本作为“参考库”,LLM 在修改时,极易受到恶意用户的“提示词注入(Prompt Injection)”攻击。 +* 比如用户在上传的 CSV 数据里或者需求里藏入恶意指令,诱导 LLM 生成 `system("rm -rf /")` 或者窃取服务器环境变量的代码。如果没有极其昂贵和复杂的微虚拟机(MicroVM,如 Firecracker)做硬件级隔离,普通的 Docker 容器一旦被攻破,后果不堪设想。 + +### **2\. 医学统计的“静默谬误”(幻觉与合规灾难)** + +在医疗和临床科研领域,**“跑通了”不等于“算对了”**。 + +* LLM 生成的代码哪怕语法完全正确,没有报错(Bug-free),它在统计学逻辑上也可能产生“幻觉”。 +* 例如,它在拼接两段代码时,可能忘记了检查数据是否符合正态分布,或者搞错了混合效应模型的随机截距项。 +* 这种错误极其隐蔽(静默谬误),系统会依然输出一个漂亮的 P 值。如果医生拿着这个错误的 P 值去发论文、做临床决策,一旦被查出,将会对您的平台声誉造成毁灭性打击。 + +### **3\. 运维与排错的“无底洞”(不可复现性)** + +* 如果系统是由预设的“宏脚本”和“固定工具”组成的,当用户报 Bug 时,您可以立刻定位:“哦,是 T 检验的脚本第 45 行出错了”,您可以轻松修复。 +* 如果代码是 LLM 每次**动态生成**的,那么每次执行的代码都是“阅后即焚”的孤品。一旦报错,您根本无法复现当时的场景,调试和维护成本将呈现指数级爆炸。 + +--- + +### **为什么我坚持用“混合模式(智能分析 \+ 宏脚本)”?** + +因为这是一种 **“用大模型的智商,用人类专家的底线”** 的降维打击策略: + +1. **大模型只做决策(不动手)**:让 LLM 做它最擅长的事——理解医生意图,阅读列名,从这 100 个工具里**选出最合适的一个或几个**,并把参数(JSON)填好。 +2. **人类专家代码执行(守底线)**:真正跑计算的,是您的 R 工程师和统计专家精心打磨过、经过千百次测试的固定脚本(包括把多个固定脚本串联起来的“宏脚本套餐”)。 +3. **用户体验完美**:在用户看来,他依然是“说了一句话,AI 帮他搞定了一切(哪怕是极度复杂的全流程)”,用户体验并没有打折,但背后的工程安全性提升了一万倍。 + +**总结来说:** 将那 100 个代码文件作为“可供 LLM 学习和动态修改的范例库”,这是极其超前的愿景,我们可以把它放在 **Phase 3(未来的终极形态)** 去探索。 + +但在眼下,我们要**打透核心业务流、让产品安全上线并赢得医生信任**,就必须将这 100 个文件视为“不可篡改的执行组件”。大模型可以作为调度员指挥它们,但绝不能赋予大模型在手术台上“随意切改代码”的权力。 + diff --git a/extraction_service/main.py b/extraction_service/main.py index 38846299..d1c23884 100644 --- a/extraction_service/main.py +++ b/extraction_service/main.py @@ -94,6 +94,8 @@ from operations.metric_time_transform import ( preview_multi_metric_to_matrix # ✨ 多指标转换预览(方向2) ) from operations.fillna import fillna_simple, fillna_mice, get_column_missing_stats +# ✨ SSA Phase 2A: 数据画像 +from operations.data_profile import generate_data_profile, get_quality_score # ==================== Pydantic Models ==================== @@ -231,6 +233,21 @@ class FillnaStatsRequest(BaseModel): column: str +# ✨ SSA Phase 2A: DataProfile 请求模型 +class DataProfileRequest(BaseModel): + """数据画像请求模型 (SSA Phase 2A)""" + data: List[Dict[str, Any]] + max_unique_values: int = 20 # 分类变量显示的最大唯一值数量 + include_quality_score: bool = True # 是否包含质量评分 + + +class DataProfileCSVRequest(BaseModel): + """数据画像请求模型 - CSV 直传 (SSA Phase 2A)""" + csv_content: str # CSV 文件内容(字符串) + max_unique_values: int = 20 + include_quality_score: bool = True + + class FillnaSimpleRequest(BaseModel): """简单填补请求模型""" data: List[Dict[str, Any]] @@ -2125,6 +2142,129 @@ async def operation_fillna_mice(request: FillnaMiceRequest): }, status_code=400) +# ==================== SSA Phase 2A: DataProfile API ==================== + +@app.post("/api/ssa/data-profile") +async def ssa_data_profile(request: DataProfileRequest): + """ + 生成数据画像 (SSA Phase 2A) + + 用于 SSA 模块在用户上传数据时快速生成数据画像, + 画像将喂给 LLM 以生成分析计划 (SAP)。 + + Args: + request: DataProfileRequest + - data: 数据 (JSON 格式) + - max_unique_values: 分类变量显示的最大唯一值数量 + - include_quality_score: 是否包含质量评分 + + Returns: + { + "success": bool, + "profile": { + "columns": [...], + "summary": {...} + }, + "quality": {...} (可选), + "execution_time": float + } + """ + try: + import pandas as pd + import time + + start_time = time.time() + + df = pd.DataFrame(request.data) + + logger.info(f"[SSA] 开始生成数据画像: {df.shape}") + + profile = generate_data_profile(df, request.max_unique_values) + + result = { + "success": True, + "profile": profile + } + + if request.include_quality_score: + result["quality"] = get_quality_score(profile) + + execution_time = time.time() - start_time + result["execution_time"] = round(execution_time, 3) + + logger.info(f"[SSA] 数据画像生成完成: {execution_time:.3f}s") + + return JSONResponse(content=result) + + except Exception as e: + logger.error(f"[SSA] 数据画像生成失败: {str(e)}") + return JSONResponse(content={ + "success": False, + "error": str(e), + "execution_time": time.time() - start_time if 'start_time' in locals() else 0 + }, status_code=400) + + +@app.post("/api/ssa/data-profile-csv") +async def ssa_data_profile_csv(request: DataProfileCSVRequest): + """ + 生成数据画像 - CSV 直传 (SSA Phase 2A) + + 直接接收 CSV 字符串,由 Python pandas 解析, + 比 Node.js 解析后再转 JSON 更高效、更可靠。 + + Args: + request: DataProfileCSVRequest + - csv_content: CSV 文件内容(字符串) + - max_unique_values: 分类变量显示的最大唯一值数量 + - include_quality_score: 是否包含质量评分 + + Returns: + { + "success": bool, + "profile": {...}, + "quality": {...} (可选), + "execution_time": float + } + """ + try: + import pandas as pd + import time + from io import StringIO + + start_time = time.time() + + # pandas 直接解析 CSV 字符串,自动推断类型 + df = pd.read_csv(StringIO(request.csv_content)) + + logger.info(f"[SSA] CSV 解析完成,开始生成数据画像: {df.shape}") + + profile = generate_data_profile(df, request.max_unique_values) + + result = { + "success": True, + "profile": profile + } + + if request.include_quality_score: + result["quality"] = get_quality_score(profile) + + execution_time = time.time() - start_time + result["execution_time"] = round(execution_time, 3) + + logger.info(f"[SSA] 数据画像生成完成 (CSV): {execution_time:.3f}s") + + return JSONResponse(content=result) + + except Exception as e: + logger.error(f"[SSA] CSV 数据画像生成失败: {str(e)}") + return JSONResponse(content={ + "success": False, + "error": str(e), + "execution_time": time.time() - start_time if 'start_time' in locals() else 0 + }, status_code=400) + + # ==================== Word 导出 API ==================== @app.get("/api/pandoc/status") diff --git a/extraction_service/operations/data_profile.py b/extraction_service/operations/data_profile.py new file mode 100644 index 00000000..2621a9b3 --- /dev/null +++ b/extraction_service/operations/data_profile.py @@ -0,0 +1,293 @@ +""" +SSA DataProfile - 数据画像生成模块 (Phase 2A) + +提供数据上传时的快速画像生成,用于 LLM 生成 SAP(分析计划)。 +高性能实现,利用 pandas 的向量化操作。 +""" + +import pandas as pd +import numpy as np +from typing import List, Dict, Any, Optional +from loguru import logger + + +def generate_data_profile(df: pd.DataFrame, max_unique_values: int = 20) -> Dict[str, Any]: + """ + 生成数据画像(DataProfile) + + Args: + df: 输入数据框 + max_unique_values: 分类变量显示的最大唯一值数量 + + Returns: + DataProfile JSON 结构 + """ + logger.info(f"开始生成数据画像: {df.shape[0]} 行, {df.shape[1]} 列") + + columns = [] + numeric_count = 0 + categorical_count = 0 + datetime_count = 0 + + for col_name in df.columns: + col = df[col_name] + col_profile = analyze_column(col, col_name, max_unique_values) + columns.append(col_profile) + + if col_profile['type'] == 'numeric': + numeric_count += 1 + elif col_profile['type'] == 'categorical': + categorical_count += 1 + elif col_profile['type'] == 'datetime': + datetime_count += 1 + + total_cells = df.shape[0] * df.shape[1] + total_missing = df.isna().sum().sum() + + summary = { + 'totalRows': int(df.shape[0]), + 'totalColumns': int(df.shape[1]), + 'numericColumns': numeric_count, + 'categoricalColumns': categorical_count, + 'datetimeColumns': datetime_count, + 'textColumns': int(df.shape[1]) - numeric_count - categorical_count - datetime_count, + 'overallMissingRate': round(total_missing / total_cells * 100, 2) if total_cells > 0 else 0, + 'totalMissingCells': int(total_missing) + } + + logger.info(f"数据画像生成完成: {numeric_count} 数值列, {categorical_count} 分类列") + + return { + 'columns': columns, + 'summary': summary + } + + +def analyze_column(col: pd.Series, col_name: str, max_unique_values: int = 20) -> Dict[str, Any]: + """ + 分析单个列的统计特征 + + Args: + col: 列数据 + col_name: 列名 + max_unique_values: 显示的最大唯一值数量 + + Returns: + 列画像 + """ + non_null = col.dropna() + missing_count = int(col.isna().sum()) + total_count = len(col) + missing_rate = round(missing_count / total_count * 100, 2) if total_count > 0 else 0 + unique_count = int(non_null.nunique()) + + col_type = infer_column_type(col, unique_count, total_count) + + profile = { + 'name': col_name, + 'type': col_type, + 'missingCount': missing_count, + 'missingRate': missing_rate, + 'uniqueCount': unique_count, + 'totalCount': total_count + } + + if col_type == 'numeric': + profile.update(analyze_numeric_column(non_null)) + elif col_type == 'categorical': + profile.update(analyze_categorical_column(non_null, max_unique_values)) + elif col_type == 'datetime': + profile.update(analyze_datetime_column(non_null)) + + return profile + + +def infer_column_type(col: pd.Series, unique_count: int, total_count: int) -> str: + """ + 推断列的数据类型 + + Returns: + 'numeric' | 'categorical' | 'datetime' | 'text' + """ + if pd.api.types.is_datetime64_any_dtype(col): + return 'datetime' + + if pd.api.types.is_numeric_dtype(col): + unique_ratio = unique_count / total_count if total_count > 0 else 0 + if unique_count <= 10 and unique_ratio < 0.05: + return 'categorical' + return 'numeric' + + if col.dtype == 'object' or col.dtype == 'string': + non_null = col.dropna() + if len(non_null) == 0: + return 'text' + + unique_ratio = unique_count / total_count if total_count > 0 else 0 + if unique_count <= 30 and unique_ratio < 0.1: + return 'categorical' + + try: + pd.to_numeric(non_null, errors='raise') + return 'numeric' + except: + pass + + try: + pd.to_datetime(non_null, errors='raise') + return 'datetime' + except: + pass + + return 'text' + + return 'text' + + +def analyze_numeric_column(col: pd.Series) -> Dict[str, Any]: + """ + 分析数值列的统计特征 + """ + if len(col) == 0: + return {} + + col_numeric = pd.to_numeric(col, errors='coerce').dropna() + + if len(col_numeric) == 0: + return {} + + q1 = float(col_numeric.quantile(0.25)) + q3 = float(col_numeric.quantile(0.75)) + iqr = q3 - q1 + lower_bound = q1 - 1.5 * iqr + upper_bound = q3 + 1.5 * iqr + outlier_count = int(((col_numeric < lower_bound) | (col_numeric > upper_bound)).sum()) + + return { + 'mean': round(float(col_numeric.mean()), 4), + 'std': round(float(col_numeric.std()), 4), + 'median': round(float(col_numeric.median()), 4), + 'min': round(float(col_numeric.min()), 4), + 'max': round(float(col_numeric.max()), 4), + 'q1': round(q1, 4), + 'q3': round(q3, 4), + 'iqr': round(iqr, 4), + 'outlierCount': outlier_count, + 'outlierRate': round(outlier_count / len(col_numeric) * 100, 2) if len(col_numeric) > 0 else 0, + 'skewness': round(float(col_numeric.skew()), 4) if len(col_numeric) >= 3 else None, + 'kurtosis': round(float(col_numeric.kurtosis()), 4) if len(col_numeric) >= 4 else None + } + + +def analyze_categorical_column(col: pd.Series, max_values: int = 20) -> Dict[str, Any]: + """ + 分析分类列的统计特征 + """ + if len(col) == 0: + return {} + + value_counts = col.value_counts() + total = len(col) + + top_values = [] + for value, count in value_counts.head(max_values).items(): + top_values.append({ + 'value': str(value), + 'count': int(count), + 'percentage': round(count / total * 100, 2) + }) + + return { + 'topValues': top_values, + 'totalLevels': int(len(value_counts)), + 'modeValue': str(value_counts.index[0]) if len(value_counts) > 0 else None, + 'modeCount': int(value_counts.iloc[0]) if len(value_counts) > 0 else 0 + } + + +def analyze_datetime_column(col: pd.Series) -> Dict[str, Any]: + """ + 分析日期时间列的统计特征 + """ + if len(col) == 0: + return {} + + try: + col_dt = pd.to_datetime(col, errors='coerce').dropna() + + if len(col_dt) == 0: + return {} + + return { + 'minDate': col_dt.min().isoformat(), + 'maxDate': col_dt.max().isoformat(), + 'dateRange': str(col_dt.max() - col_dt.min()) + } + except: + return {} + + +def get_quality_score(profile: Dict[str, Any]) -> Dict[str, Any]: + """ + 计算数据质量评分 + + Returns: + 质量评分和建议 + """ + summary = profile.get('summary', {}) + columns = profile.get('columns', []) + + score = 100.0 + issues = [] + recommendations = [] + + overall_missing_rate = summary.get('overallMissingRate', 0) + if overall_missing_rate > 20: + score -= 30 + issues.append(f"整体缺失率较高 ({overall_missing_rate}%)") + recommendations.append("建议检查数据完整性,考虑缺失值处理") + elif overall_missing_rate > 10: + score -= 15 + issues.append(f"整体缺失率中等 ({overall_missing_rate}%)") + recommendations.append("建议在分析前处理缺失值") + elif overall_missing_rate > 5: + score -= 5 + issues.append(f"存在少量缺失 ({overall_missing_rate}%)") + + for col in columns: + if col.get('outlierRate', 0) > 10: + score -= 5 + issues.append(f"列 '{col['name']}' 存在较多异常值 ({col['outlierRate']}%)") + recommendations.append(f"建议检查列 '{col['name']}' 的异常值") + + total_rows = summary.get('totalRows', 0) + if total_rows < 30: + score -= 20 + issues.append(f"样本量较小 (n={total_rows})") + recommendations.append("小样本可能影响统计检验的效力") + elif total_rows < 100: + score -= 10 + issues.append(f"样本量中等 (n={total_rows})") + + score = max(0, min(100, score)) + + if score >= 80: + grade = 'A' + grade_desc = '数据质量良好' + elif score >= 60: + grade = 'B' + grade_desc = '数据质量中等' + elif score >= 40: + grade = 'C' + grade_desc = '数据质量较差' + else: + grade = 'D' + grade_desc = '数据质量很差' + + return { + 'score': round(score, 1), + 'grade': grade, + 'gradeDescription': grade_desc, + 'issues': issues, + 'recommendations': recommendations + } diff --git a/frontend-v2/src/modules/ssa/SSAWorkspace.tsx b/frontend-v2/src/modules/ssa/SSAWorkspace.tsx index 197bde17..170221d9 100644 --- a/frontend-v2/src/modules/ssa/SSAWorkspace.tsx +++ b/frontend-v2/src/modules/ssa/SSAWorkspace.tsx @@ -18,6 +18,7 @@ import { SSAChatPane } from './components/SSAChatPane'; import { SSAWorkspacePane } from './components/SSAWorkspacePane'; import { SSACodeModal } from './components/SSACodeModal'; import { SSAToast } from './components/SSAToast'; +import { DataProfileModal } from './components/DataProfileModal'; const SSAWorkspace: React.FC = () => { const { @@ -83,6 +84,9 @@ const SSAWorkspace: React.FC = () => { {/* 代码模态框 */} + + {/* Phase 2A: 数据质量报告详情模态框 */} +
); }; diff --git a/frontend-v2/src/modules/ssa/components/ConclusionReport.tsx b/frontend-v2/src/modules/ssa/components/ConclusionReport.tsx new file mode 100644 index 00000000..5f7aa67e --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/ConclusionReport.tsx @@ -0,0 +1,233 @@ +/** + * 综合结论报告组件 + * + * Phase 2A: 多步骤工作流执行完成后的综合结论展示 + */ +import React, { useState } from 'react'; +import type { ConclusionReport as ConclusionReportType, WorkflowStepResult } from '../types'; + +interface ConclusionReportProps { + report: ConclusionReportType; + stepResults?: WorkflowStepResult[]; +} + +interface StepResultDetailProps { + stepSummary: ConclusionReportType['step_summaries'][0]; + stepResult?: WorkflowStepResult; +} + +const StepResultDetail: React.FC = ({ stepSummary, stepResult }) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)}> +
+ 步骤 {stepSummary.step_number} + {stepSummary.tool_name} + {stepSummary.p_value !== undefined && ( + + p = {stepSummary.p_value < 0.001 ? '< 0.001' : stepSummary.p_value.toFixed(4)} + {stepSummary.is_significant && ' *'} + + )} +
+ +
+ +
{stepSummary.summary}
+ + {expanded && stepResult?.result && ( +
+ {/* 结果表格 */} + {stepResult.result.result_table && ( +
+
+ + + {stepResult.result.result_table.headers.map((header, idx) => ( + + ))} + + + + {stepResult.result.result_table.rows.map((row, rowIdx) => ( + + {row.map((cell, cellIdx) => ( + + ))} + + ))} + +
{header}
{typeof cell === 'number' ? cell.toFixed(4) : cell}
+ + )} + + {/* 图表 */} + {stepResult.result.plots && stepResult.result.plots.length > 0 && ( +
+ {stepResult.result.plots.map((plot, idx) => ( +
+
{plot.title}
+ {plot.title} +
+ ))} +
+ )} + + {/* 详细解释 */} + {stepResult.result.interpretation && ( +
+ 💡 解读: +

{stepResult.result.interpretation}

+
+ )} + + )} + + ); +}; + +export const ConclusionReport: React.FC = ({ report, stepResults = [] }) => { + const [showFullReport, setShowFullReport] = useState(true); + + const getStepResult = (stepNumber: number): WorkflowStepResult | undefined => { + return stepResults.find(r => r.step_number === stepNumber); + }; + + return ( +
+ {/* 报告头部 */} +
+

📋 {report.title}

+ + {new Date(report.generated_at).toLocaleString('zh-CN')} + +
+ + {/* AI 总结摘要 - 始终显示 */} +
+
+ 🤖 + AI 综合结论 +
+
+ {report.executive_summary} +
+
+ + {/* 主要发现 */} + {report.key_findings.length > 0 && ( +
+
+ 🎯 + 主要发现 +
+
    + {report.key_findings.map((finding, idx) => ( +
  • {finding}
  • + ))} +
+
+ )} + + {/* 统计概览 */} +
+
+ 📊 + {report.statistical_summary.total_tests} + 统计检验 +
+
+ + {report.statistical_summary.significant_results} + 显著结果 +
+
+ 🔬 + {report.statistical_summary.methods_used.length} + 分析方法 +
+
+ + {/* 展开/折叠按钮 */} + + + {/* 详细步骤结果 */} + {showFullReport && ( +
+
+ 📝 + 详细分析结果 +
+
+ {report.step_summaries.map((stepSummary) => ( + + ))} +
+
+ )} + + {/* 建议 */} + {report.recommendations.length > 0 && ( +
+
+ 💡 + 建议 +
+
    + {report.recommendations.map((rec, idx) => ( +
  • {rec}
  • + ))} +
+
+ )} + + {/* 局限性 */} + {report.limitations.length > 0 && ( +
+
+ ⚠️ + 局限性 +
+
    + {report.limitations.map((lim, idx) => ( +
  • {lim}
  • + ))} +
+
+ )} + + {/* 使用的方法列表 */} +
+ 使用的分析方法: +
+ {report.statistical_summary.methods_used.map((method, idx) => ( + {method} + ))} +
+
+
+ ); +}; + +export default ConclusionReport; diff --git a/frontend-v2/src/modules/ssa/components/DataProfileCard.tsx b/frontend-v2/src/modules/ssa/components/DataProfileCard.tsx new file mode 100644 index 00000000..d5cacd2b --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/DataProfileCard.tsx @@ -0,0 +1,145 @@ +/** + * 数据质量核查报告卡片组件 + * + * Phase 2A: 在对话区显示数据上传后的质量核查摘要 + * 设计风格与 SAP 卡片保持一致 + */ +import React from 'react'; +import { useSSAStore } from '../stores/ssaStore'; +import type { DataProfile, DataQualityGrade } from '../types'; + +interface DataProfileCardProps { + profile: DataProfile; + compact?: boolean; +} + +const gradeConfig: Record = { + A: { color: '#059669', bg: '#ecfdf5', label: '优秀' }, + B: { color: '#2563eb', bg: '#eff6ff', label: '良好' }, + C: { color: '#d97706', bg: '#fffbeb', label: '一般' }, + D: { color: '#dc2626', bg: '#fef2f2', label: '需改进' }, +}; + +export const DataProfileCard: React.FC = ({ profile, compact = false }) => { + const { setDataProfileModalVisible } = useSSAStore(); + const grade = gradeConfig[profile.quality_grade] || gradeConfig.C; + + const handleViewDetails = () => { + setDataProfileModalVisible(true); + }; + + if (compact) { + return ( +
+
+ 📊 + 数据质量核查完成 + + {grade.label} ({profile.quality_score}分) + +
+
+ {profile.row_count} 行 × {profile.column_count} 列 + | + 缺失率 {(profile.missing_ratio * 100).toFixed(1)}% + {profile.warnings.length > 0 && ( + <> + | + ⚠️ {profile.warnings.length} 个警告 + + )} +
+ +
+ ); + } + + return ( +
+
+
+ 📊 + 数据质量核查报告 +
+
+ {profile.quality_grade} + {profile.quality_score}分 +
+
+ +
+
+
+ 数据规模 + {profile.row_count.toLocaleString()} 行 × {profile.column_count} 列 +
+
+ 缺失率 + 0.1 ? '#d97706' : '#059669' }}> + {(profile.missing_ratio * 100).toFixed(2)}% + +
+
+ 重复行 + 0.05 ? '#d97706' : '#059669' }}> + {profile.duplicate_rows} ({(profile.duplicate_ratio * 100).toFixed(2)}%) + +
+
+ 变量类型 + + 数值 {profile.numeric_columns} / 分类 {profile.categorical_columns} + +
+
+ + {profile.warnings.length > 0 && ( +
+
+ ⚠️ + 数据警告 ({profile.warnings.length}) +
+
    + {profile.warnings.slice(0, 3).map((warning, idx) => ( +
  • {warning}
  • + ))} + {profile.warnings.length > 3 && ( +
  • 还有 {profile.warnings.length - 3} 个警告...
  • + )} +
+
+ )} + + {profile.recommendations.length > 0 && ( +
+
+ 💡 + 建议 +
+
    + {profile.recommendations.slice(0, 2).map((rec, idx) => ( +
  • {rec}
  • + ))} +
+
+ )} +
+ +
+ +
+
+ ); +}; + +export default DataProfileCard; diff --git a/frontend-v2/src/modules/ssa/components/DataProfileModal.tsx b/frontend-v2/src/modules/ssa/components/DataProfileModal.tsx new file mode 100644 index 00000000..269a6688 --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/DataProfileModal.tsx @@ -0,0 +1,284 @@ +/** + * 数据质量核查报告详情模态框 + * + * Phase 2A: 显示完整的数据画像报告 + */ +import React from 'react'; +import { useSSAStore } from '../stores/ssaStore'; +import type { ColumnProfile, DataQualityGrade } from '../types'; + +const gradeConfig: Record = { + A: { color: '#059669', bg: '#ecfdf5', label: '优秀' }, + B: { color: '#2563eb', bg: '#eff6ff', label: '良好' }, + C: { color: '#d97706', bg: '#fffbeb', label: '一般' }, + D: { color: '#dc2626', bg: '#fef2f2', label: '需改进' }, +}; + +const typeIcons: Record = { + numeric: '🔢', + categorical: '📋', + datetime: '📅', + text: '📝', +}; + +interface ColumnDetailProps { + column: ColumnProfile; +} + +const ColumnDetail: React.FC = ({ column }) => { + const isNumeric = column.inferred_type === 'numeric'; + + return ( +
+
+ {typeIcons[column.inferred_type] || '📄'} + {column.name} + {column.inferred_type} +
+ +
+
+ 非空值 + {column.non_null_count.toLocaleString()} +
+
+ 缺失率 + 0.1 ? '#d97706' : '#64748b' }} + > + {(column.null_ratio * 100).toFixed(1)}% + +
+
+ 唯一值 + {column.unique_count.toLocaleString()} +
+ + {isNumeric && ( + <> +
+ 均值 ± 标准差 + + {column.mean?.toFixed(2)} ± {column.std?.toFixed(2)} + +
+
+ 范围 + + [{column.min?.toFixed(2)}, {column.max?.toFixed(2)}] + +
+
+ 中位数 (Q1-Q3) + + {column.median?.toFixed(2)} ({column.q1?.toFixed(2)} - {column.q3?.toFixed(2)}) + +
+ {column.outlier_count !== undefined && column.outlier_count > 0 && ( +
+ ⚠️ 异常值 + + {column.outlier_count} ({(column.outlier_ratio! * 100).toFixed(1)}%) + +
+ )} + + )} + + {column.top_categories && column.top_categories.length > 0 && ( +
+ 主要类别 +
+ {column.top_categories.slice(0, 5).map((cat, idx) => ( +
+
+ {cat.value} + {cat.count} +
+ ))} +
+
+ )} +
+ +
+ 样本值: + + {column.sample_values.slice(0, 5).map(v => + v === null ? 'NULL' : String(v) + ).join(', ')} + +
+
+ ); +}; + +export const DataProfileModal: React.FC = () => { + const { dataProfile, dataProfileModalVisible, setDataProfileModalVisible } = useSSAStore(); + + if (!dataProfileModalVisible || !dataProfile) { + return null; + } + + const grade = gradeConfig[dataProfile.quality_grade] || gradeConfig.C; + + const handleClose = () => { + setDataProfileModalVisible(false); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + handleClose(); + } + }; + + return ( +
+
+
+
+

📊 数据质量核查报告

+ {dataProfile.file_name} +
+ +
+ +
+ {/* 概览卡片 */} +
+
+
+ {dataProfile.quality_grade} + {dataProfile.quality_score}分 +
+
{grade.label}
+
+ +
+
+ 📋 + {dataProfile.row_count.toLocaleString()} + 行数 +
+
+ 📊 + {dataProfile.column_count} + 列数 +
+
+ + {dataProfile.total_cells.toLocaleString()} + 总单元格 +
+
+ + 0.1 ? '#d97706' : '#64748b' }}> + {(dataProfile.missing_ratio * 100).toFixed(1)}% + + 缺失率 +
+
+ 🔄 + 0.05 ? '#d97706' : '#64748b' }}> + {dataProfile.duplicate_rows} + + 重复行 +
+
+
+ + {/* 变量类型分布 */} +
+

变量类型分布

+
+
+ 🔢 数值型 +
+
+
+ {dataProfile.numeric_columns} +
+
+ 📋 分类型 +
+
+
+ {dataProfile.categorical_columns} +
+
+ 📅 日期型 +
+
+
+ {dataProfile.datetime_columns} +
+
+
+ + {/* 警告和建议 */} + {(dataProfile.warnings.length > 0 || dataProfile.recommendations.length > 0) && ( +
+ {dataProfile.warnings.length > 0 && ( +
+

⚠️ 数据警告

+
    + {dataProfile.warnings.map((warning, idx) => ( +
  • {warning}
  • + ))} +
+
+ )} + {dataProfile.recommendations.length > 0 && ( +
+

💡 建议

+
    + {dataProfile.recommendations.map((rec, idx) => ( +
  • {rec}
  • + ))} +
+
+ )} +
+ )} + + {/* 列详情 */} +
+

列详情 ({dataProfile.columns.length})

+
+ {dataProfile.columns.map((column, idx) => ( + + ))} +
+
+
+ +
+ + 生成时间: {new Date(dataProfile.generated_at).toLocaleString('zh-CN')} + + +
+
+
+ ); +}; + +export default DataProfileModal; diff --git a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx index d2b56a07..9b548825 100644 --- a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx @@ -17,16 +17,18 @@ import { ArrowLeft, FileSignature, ArrowRight, - Zap, Loader2, AlertCircle, - CheckCircle + CheckCircle, + BarChart2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useSSAStore } from '../stores/ssaStore'; import { useAnalysis } from '../hooks/useAnalysis'; +import { useWorkflow } from '../hooks/useWorkflow'; import type { SSAMessage } from '../types'; import { TypeWriter } from './TypeWriter'; +import { DataProfileCard } from './DataProfileCard'; export const SSAChatPane: React.FC = () => { const navigate = useNavigate(); @@ -45,9 +47,12 @@ export const SSAChatPane: React.FC = () => { setError, addToast, selectAnalysisRecord, + dataProfile, + dataProfileLoading, } = useSSAStore(); const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis(); + const { generateDataProfile, generateWorkflowPlan, isProfileLoading, isPlanLoading } = useWorkflow(); const [inputValue, setInputValue] = useState(''); const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'parsing' | 'success' | 'error'>('idle'); const fileInputRef = useRef(null); @@ -109,7 +114,14 @@ export const SSAChatPane: React.FC = () => { rowCount: result.schema.rowCount, }); setUploadStatus('success'); - addToast('数据读取成功,正在分析结构...', 'success'); + addToast('数据读取成功,正在进行质量核查...', 'success'); + + // Phase 2A: 自动触发数据质量核查 + try { + await generateDataProfile(result.sessionId); + } catch (profileErr) { + console.warn('数据画像生成失败,继续使用基础模式:', profileErr); + } } catch (err: any) { setUploadStatus('error'); const errorMsg = err?.message || '上传失败,请检查文件格式'; @@ -132,7 +144,13 @@ export const SSAChatPane: React.FC = () => { if (!inputValue.trim()) return; try { - await generatePlan(inputValue); + // Phase 2A: 如果已有 session,使用多步骤工作流规划 + if (currentSession?.id) { + await generateWorkflowPlan(currentSession.id, inputValue); + } else { + // 没有数据时,使用旧流程 + await generatePlan(inputValue); + } setInputValue(''); } catch (err: any) { addToast(err?.message || '生成计划失败', 'error'); @@ -178,8 +196,9 @@ export const SSAChatPane: React.FC = () => {
@@ -198,6 +217,18 @@ export const SSAChatPane: React.FC = () => {
+ {/* Phase 2A: 数据质量核查报告卡片 - 在欢迎语之后、用户消息之前显示 */} + {dataProfile && ( +
+
+ +
+
+ +
+
+ )} + {/* 动态消息 */} {messages.map((msg: SSAMessage, idx: number) => { const isLastAiMessage = msg.role === 'assistant' && idx === messages.length - 1; @@ -219,8 +250,8 @@ export const SSAChatPane: React.FC = () => { msg.content )} - {/* SAP 卡片 */} - {msg.artifactType === 'sap' && ( + {/* SAP 卡片 - 只有消息中明确标记为 sap 类型时才显示 */} + {msg.artifactType === 'sap' && msg.recordId && ( -
- - )} + {/* + Phase 2A 新流程: + 1. 上传数据 → 显示数据质量报告(已在上方处理) + 2. 用户输入分析问题 → AI 回复消息中包含 SAP 卡片(通过 msg.artifactType === 'sap') + 旧的"数据挂载成功消息"已移除,避免在没有用户输入时就显示 SAP 卡片 + */} {/* 自动滚动锚点与占位垫片 - 解决底部输入框遮挡的终极方案 */}
@@ -409,12 +435,14 @@ interface EngineStatusProps { isExecuting: boolean; isLoading: boolean; isUploading: boolean; + isProfileLoading?: boolean; } const EngineStatus: React.FC = ({ isExecuting, isLoading, - isUploading + isUploading, + isProfileLoading }) => { const getStatus = () => { if (isExecuting) { @@ -423,6 +451,9 @@ const EngineStatus: React.FC = ({ if (isLoading) { return { text: 'AI Processing...', className: 'status-processing' }; } + if (isProfileLoading) { + return { text: 'Data Profiling...', className: 'status-profiling' }; + } if (isUploading) { return { text: 'Parsing Data...', className: 'status-uploading' }; } diff --git a/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx b/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx index 1fc0ada9..3d28ec67 100644 --- a/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx +++ b/frontend-v2/src/modules/ssa/components/SSACodeModal.tsx @@ -10,7 +10,7 @@ import { useSSAStore } from '../stores/ssaStore'; import { useAnalysis } from '../hooks/useAnalysis'; export const SSACodeModal: React.FC = () => { - const { codeModalVisible, setCodeModalVisible, executionResult, addToast } = useSSAStore(); + const { codeModalVisible, setCodeModalVisible, executionResult, addToast, isWorkflowMode, workflowSteps } = useSSAStore(); const { downloadCode } = useAnalysis(); const [code, setCode] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -24,9 +24,21 @@ export const SSACodeModal: React.FC = () => { const loadCode = async () => { setIsLoading(true); try { - const result = await downloadCode(); - const text = await result.blob.text(); - setCode(text); + if (isWorkflowMode && workflowSteps.length > 0) { + const allCode = workflowSteps + .filter(s => s.status === 'success' && s.result) + .map(s => { + const stepCode = (s.result as any)?.reproducible_code; + const header = `# ========================================\n# 步骤 ${s.step_number}: ${s.tool_name}\n# ========================================\n`; + 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); @@ -46,15 +58,27 @@ export const SSACodeModal: React.FC = () => { const handleDownload = async () => { try { - 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(); + 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) { addToast('下载失败', 'error'); } diff --git a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx index f4c80f14..1e63d402 100644 --- a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx @@ -22,10 +22,13 @@ import { FileQuestion, BarChart3, ImageOff, + StopCircle, } from 'lucide-react'; import { useSSAStore } from '../stores/ssaStore'; import { useAnalysis } from '../hooks/useAnalysis'; +import { useWorkflow } from '../hooks/useWorkflow'; import type { TraceStep } from '../types'; +import { WorkflowTimeline } from './WorkflowTimeline'; type ExecutionPhase = 'planning' | 'executing' | 'completed' | 'error'; @@ -39,9 +42,17 @@ export const SSAWorkspacePane: React.FC = () => { setCodeModalVisible, addToast, currentRecordId, + currentSession, + // Phase 2A: 多步骤工作流状态 + isWorkflowMode, + workflowPlan, + workflowSteps, + workflowProgress, + conclusionReport, } = 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'); @@ -50,20 +61,19 @@ export const SSAWorkspacePane: React.FC = () => { const resultRef = useRef(null); const containerRef = useRef(null); + const hasWorkflowResults = isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result); + // 当切换记录或执行结果变化时,同步 phase 状态 useEffect(() => { - // 如果正在执行中,不要覆盖 phase - if (isExecuting) return; + if (isExecuting || isWorkflowExecuting) return; - // 根据当前记录的执行结果来判断 phase - if (analysisResult) { + if (analysisResult || hasWorkflowResults) { setPhase('completed'); } else { - // 没有执行结果,重置为 planning setPhase('planning'); } setExecutionError(null); - }, [currentRecordId, analysisResult, isExecuting]); + }, [currentRecordId, analysisResult, isExecuting, isWorkflowExecuting, hasWorkflowResults]); useEffect(() => { let timer: NodeJS.Timeout; @@ -93,12 +103,19 @@ export const SSAWorkspacePane: React.FC = () => { }; const handleRun = async () => { - if (!currentPlan) return; setPhase('executing'); setExecutionError(null); + try { - await executeAnalysis(); - setPhase('completed'); + // Phase 2A: 多步骤工作流模式 + if (isWorkflowMode && workflowPlan && currentSession) { + await executeWorkflow(currentSession.id, workflowPlan.workflow_id); + setPhase('completed'); + } else if (currentPlan) { + // 单步骤模式(兼容原有逻辑) + await executeAnalysis(); + setPhase('completed'); + } } catch (err: any) { const errorMsg = err?.message || '执行失败,请重试'; setExecutionError(errorMsg); @@ -107,14 +124,24 @@ export const SSAWorkspacePane: React.FC = () => { } }; + const handleCancel = () => { + cancelWorkflow(); + setPhase('planning'); + addToast('分析已取消', 'info'); + }; + const handleRetry = () => { setExecutionError(null); setPhase('planning'); }; - const handleExportReport = () => { - exportReport(); - addToast('报告导出成功', 'success'); + const handleExportReport = async () => { + try { + await exportReport(); + addToast('报告导出成功', 'success'); + } catch (err: any) { + addToast(err?.message || '导出失败', 'error'); + } }; const handleExportCode = () => { @@ -169,7 +196,7 @@ export const SSAWorkspacePane: React.FC = () => {
{/* 导出报告按钮 - 有结果时显示 */} - {analysisResult && ( + {(analysisResult || hasWorkflowResults) && ( )} {/* 查看代码按钮 - 有结果时显示 */} - {analysisResult && ( + {(analysisResult || hasWorkflowResults) && ( + ) : phase !== 'planning' && conclusionReport ? ( + + ) : ( + + )}
- + + ) : currentPlan && ( +
+

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

- {/* 统计护栏 */} -
-

2. 统计护栏与执行策略

-
    - {(currentPlan.guardrails || []).length > 0 ? ( - currentPlan.guardrails.map((guardrail, idx) => ( -
  • +
    + {/* 推荐统计方法 */} +
    +

    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 || '-'} + (数值) +
    +
    +
    +
    + + {/* 统计护栏 */} +
    +

    2. 统计护栏与执行策略

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

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

- {elapsedTime}s -
-
+ {(isWorkflowExecuting || phase === 'executing') && ( +
+ +

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

+ {Math.round(workflowProgress)}% +
+ )} + + {phase === 'completed' && ( +
+ + 全部步骤执行完成 + + 共 {workflowSteps.length} 个步骤 + +
+ )} + + {/* 复用 MVP 的 terminal-box 风格 */} +
- {traceSteps.length === 0 ? ( -
- - 等待 R 引擎响应... -
- ) : ( - traceSteps.map((step, idx) => ( - - )) - )} -
-
-
- )} - - {/* 执行完成后的日志(折叠显示) */} - {phase === 'completed' && traceSteps.length > 0 && ( -
-
- - 执行完成 - 耗时 {analysisResult?.executionMs || 0}ms -
-
-
- {traceSteps.map((step, idx) => ( - + {workflowSteps.flatMap((step) => { + const logs: Array<{ name: string; status: string; message?: string }> = []; + 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 : '' + }); + (step.logs || []).forEach(log => { + logs.push({ name: ` → ${log}`, status: 'info' }); + }); + return logs; + }).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) => ( + + ))} +
+
+
+ )} + )} {/* 执行错误 */} @@ -389,13 +499,181 @@ export const SSAWorkspacePane: React.FC = () => { )} {/* ========== 区块 3: 分析结果 ========== */} - {analysisResult && ( + {(analysisResult || (isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result))) && (
分析结果
+ + {/* Phase 2A: 多步骤工作流结果 - 复用 MVP 风格 */} + {isWorkflowMode && workflowSteps.some(s => s.status === 'success' && s.result) ? ( +
+ {/* AI 解读 - 使用结论报告的摘要 */} + {conclusionReport && ( +
+ +
+

AI 统计解读

+

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

+
+
+ )} + + {/* 各步骤结果汇总 - MVP 风格 */} + {workflowSteps.filter(s => s.status === 'success' && s.result).map((step, stepIdx) => { + const r = step.result as any; + const pVal = r?.p_value ?? r?.pValue; + const pFmt = r?.p_value_fmt || (pVal !== undefined ? formatPValue(pVal) : undefined); + const isDescriptive = step.tool_code === 'ST_DESCRIPTIVE' || r?.method === '描述性统计'; + + return ( +
+

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

+ + {/* 描述性统计 - 专用渲染 */} + {isDescriptive ? ( + + ) : ( + <> + {/* 非描述性统计 - 统计量汇总 */} +
+
+
统计方法
+
{r?.method || step.tool_name}
+
+ {r?.statistic !== undefined && ( +
+
统计量
+
{Number(r.statistic).toFixed(4)}
+
+ )} + {pVal !== undefined && ( +
+
P 值
+
+ {pFmt} +
+
+ )} + {r?.effect_size !== undefined && ( +
+
效应量
+
+ {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)} +
+
+ )} + {r?.conf_int && ( +
+
95% CI
+
[{r.conf_int.map((v: number) => v.toFixed(3)).join(', ')}]
+
+ )} + {r?.coefficients && !Array.isArray(r.coefficients) && ( +
+
系数数量
+
{Object.keys(r.coefficients).length}
+
+ )} +
+ + {/* 分组统计表 */} + {r?.group_stats?.length > 0 && ( +
+

分组统计

+
+ + + + + + {r.group_stats.map((g: any, i: number) => ( + + + + + + + ))} + +
分组N均值标准差
{g.group}{g.n}{g.mean !== undefined ? Number(g.mean).toFixed(4) : '-'}{g.sd !== undefined ? Number(g.sd).toFixed(4) : '-'}
+
+
+ )} + + {/* Logistic 回归系数表 */} + {r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0 && ( +
+

回归系数

+
+ + + + + + {r.coefficients.map((c: any, i: number) => ( + + + + + + + + ))} + +
变量估计值OR95% CIP 值
{c.variable || c.term}{Number(c.estimate || c.coef || 0).toFixed(4)}{c.OR !== undefined ? Number(c.OR).toFixed(4) : '-'}{c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-'}{c.p_value_fmt || formatPValue(c.p_value)}
+
+
+ )} + + {/* 详细表格数据 result_table */} + {r?.result_table && ( +
+
+ + {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)}
+
+
+ )} + + )} + + {/* 图表 - 所有类型通用 */} + {r?.plots?.length > 0 && ( +
+

Figure {stepIdx + 1}. 可视化

+ {r.plots.map((plot: any, plotIdx: number) => ( + + ))} +
+ )} +
+ ); + })} + + {/* 综合执行时间 */} +
+ 总执行耗时: {workflowSteps.reduce((sum, s) => sum + (s.duration_ms || 0), 0)}ms +
+
+ ) : analysisResult && (
{/* AI 解读 */}
@@ -519,6 +797,7 @@ export const SSAWorkspacePane: React.FC = () => { 执行耗时: {analysisResult.executionMs}ms
+ )}
)} @@ -652,4 +931,189 @@ 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 } } } + */ +const DescriptiveResultView: React.FC<{ result: any }> = ({ result }) => { + if (!result) return null; + + const summary = result.summary; + const variables = result.variables; + + const classifyVar = (v: any): 'numeric' | 'categorical' | 'unknown' => { + if (!v) return 'unknown'; + if (v.type === 'numeric') return 'numeric'; + if (v.type === 'categorical') return 'categorical'; + if (v.mean !== undefined || v.sd !== undefined) return 'numeric'; + if (v.levels && Array.isArray(v.levels)) return 'categorical'; + if (v.by_group) { + const firstGroup = Object.values(v.by_group)[0] as any; + if (firstGroup?.mean !== undefined) return 'numeric'; + if (firstGroup?.levels) return 'categorical'; + } + return 'unknown'; + }; + + 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'); + + const getNumericStats = (vs: any) => { + 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 vs; + }; + + return ( + <> + {summary && ( +
+
+
总观测数
+
{summary.n_total ?? '-'}
+
+
+
分析变量数
+
{summary.n_variables ?? '-'}
+
+
+
数值变量
+
{summary.n_numeric ?? '-'}
+
+
+
分类变量
+
{summary.n_categorical ?? '-'}
+
+
+ )} + + {numericVars.length > 0 && ( +
+

数值变量统计

+
+ + + + + + + + + + + + + + + + {numericVars.map(([varName, rawVs]) => { + const vs = getNumericStats(rawVs as any); + if (vs.by_group) { + return Object.entries(vs.by_group).map(([gName, gStats]: [string, any]) => ( + + + + + + + + + + + + )); + } + return ( + + + + + + + + + + + + ); + })} + +
变量N均值 ± 标准差中位数Q1Q3最小值最大值缺失
{vs.variable || varName} [{gName}]{gStats.n ?? '-'}{gStats.formatted || (gStats.mean !== undefined ? `${gStats.mean} ± ${gStats.sd}` : '-')}{gStats.median ?? '-'}{gStats.q1 ?? '-'}{gStats.q3 ?? '-'}{gStats.min ?? '-'}{gStats.max ?? '-'}{gStats.missing ?? 0}
{vs.variable || varName}{vs.n ?? '-'}{vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-')}{vs.median ?? '-'}{vs.q1 ?? '-'}{vs.q3 ?? '-'}{vs.min ?? '-'}{vs.max ?? '-'}{vs.missing ?? 0}
+
+
+ )} + + {catVars.length > 0 && ( +
+

分类变量统计

+
+ + + + + + + + + + + + {catVars.flatMap(([varName, rawVs]) => { + const vs = rawVs as any; + if (vs.by_group) { + return Object.entries(vs.by_group).flatMap(([gName, gStats]: [string, any]) => { + const levels = gStats.levels || []; + if (levels.length === 0) { + return []; + } + return levels.map((lv: any, i: number) => ( + + {i === 0 && } + {i === 0 && } + + + {i === 0 && } + + )); + }); + } + const levels = vs.levels || []; + if (levels.length === 0) { + return []; + } + return levels.map((lv: any, i: number) => ( + + {i === 0 && } + {i === 0 && } + + + {i === 0 && } + + )); + })} + +
变量N类别频数 (%)缺失
{vs.variable || varName} [{gName}]{gStats.n ?? '-'}--{gStats.missing ?? 0}
{vs.variable || varName} [{gName}]{gStats.n ?? '-'}{lv.level}{lv.formatted || `${lv.n} (${lv.pct}%)`}{gStats.missing ?? 0}
{vs.variable || varName}{vs.n ?? '-'}--{vs.missing ?? 0}
{vs.variable || varName}{vs.n ?? '-'}{lv.level}{lv.formatted || `${lv.n} (${lv.pct}%)`}{vs.missing ?? 0}
+
+
+ )} + + {varEntries.length === 0 && !summary && ( +
+
{JSON.stringify(result, null, 2)}
+
+ )} + + ); +}; + export default SSAWorkspacePane; diff --git a/frontend-v2/src/modules/ssa/components/StepProgressCard.tsx b/frontend-v2/src/modules/ssa/components/StepProgressCard.tsx new file mode 100644 index 00000000..2ca8a277 --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/StepProgressCard.tsx @@ -0,0 +1,163 @@ +/** + * 步骤执行进度卡片组件 + * + * Phase 2A: 在执行日志区域显示每个步骤的详细进度和日志 + */ +import React, { useState } from 'react'; +import type { WorkflowStepResult, WorkflowStepStatus } from '../types'; + +interface StepProgressCardProps { + step: WorkflowStepResult; + isExpanded?: boolean; + onToggle?: () => void; +} + +const statusConfig: Record = { + pending: { icon: '⏳', label: '等待中', color: '#94a3b8', bg: '#f1f5f9' }, + running: { icon: '⚡', label: '执行中', color: '#2563eb', bg: '#eff6ff' }, + success: { icon: '✅', label: '成功', color: '#059669', bg: '#ecfdf5' }, + failed: { icon: '❌', label: '失败', color: '#dc2626', bg: '#fef2f2' }, + skipped: { icon: '⏭️', label: '跳过', color: '#94a3b8', bg: '#f8fafc' }, + warning: { icon: '⚠️', label: '警告', color: '#d97706', bg: '#fffbeb' }, +}; + +export const StepProgressCard: React.FC = ({ + step, + isExpanded = false, + onToggle, +}) => { + const [expanded, setExpanded] = useState(isExpanded); + const config = statusConfig[step.status]; + + const handleToggle = () => { + setExpanded(!expanded); + onToggle?.(); + }; + + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString('zh-CN'); + }; + + return ( +
+
+
+ + {config.icon} + + + 步骤 {step.step_number} + {step.tool_name} + +
+
+ + {config.label} + + {step.duration_ms && ( + {step.duration_ms}ms + )} + +
+
+ + {expanded && ( +
+ {/* 时间信息 */} +
+ 开始: {formatTime(step.started_at)} + {step.completed_at && ( + 结束: {formatTime(step.completed_at)} + )} +
+ + {/* 执行日志 */} + {step.logs.length > 0 && ( +
+
📋 执行日志
+
+ {step.logs.map((log, idx) => ( +
+ {'>'} + {log} +
+ ))} +
+
+ )} + + {/* 结果预览 */} + {step.status === 'success' && step.result && ( +
+
📊 结果摘要
+
+ {step.result.method && ( +
+ 方法: + {step.result.method} +
+ )} + {step.result.statistic !== undefined && ( +
+ 统计量: + {step.result.statistic.toFixed(4)} +
+ )} + {step.result.p_value !== undefined && ( +
+ P值: + + {step.result.p_value < 0.001 ? '< 0.001' : step.result.p_value.toFixed(4)} + {step.result.p_value < 0.05 && ' *'} + +
+ )} + {step.result.effect_size !== undefined && ( +
+ 效应量: + {step.result.effect_size.toFixed(4)} +
+ )} + {step.result.interpretation && ( +
+ {step.result.interpretation} +
+ )} +
+
+ )} + + {/* 错误信息 */} + {step.status === 'failed' && step.error && ( +
+
❌ 错误信息
+
{step.error}
+
+ )} +
+ )} +
+ ); +}; + +export default StepProgressCard; diff --git a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx new file mode 100644 index 00000000..f4fcdac1 --- /dev/null +++ b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx @@ -0,0 +1,184 @@ +/** + * 多步骤工作流时间线组件 + * + * Phase 2A: 在工作区显示多步骤分析计划的垂直时间线 + */ +import React from 'react'; +import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types'; + +interface WorkflowTimelineProps { + plan: WorkflowPlan; + stepResults?: WorkflowStepResult[]; + currentStep?: number; + 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' }, +}; + +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 getToolIcon = (toolCode: string): string => { + return toolIcons[toolCode] || toolIcons.default; +}; + +interface StepItemProps { + 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]; + + return ( +
+
+
+ {config.icon} +
+ {!isLast &&
} +
+ +
+
+ 步骤 {step.step_number} + {getToolIcon(step.tool_code)} + {step.tool_name} + {result?.duration_ms && ( + {result.duration_ms}ms + )} +
+ +
{step.description}
+ + {step.params && Object.keys(step.params).length > 0 && ( +
+ {Object.entries(step.params).slice(0, 3).map(([key, value]) => ( + + {key}: {String(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?.status === 'failed' && result.error && ( +
+ ⚠️ + {result.error} +
+ )} +
+
+ ); +}; + +export const WorkflowTimeline: React.FC = ({ + plan, + stepResults = [], + 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; + + return ( +
+
+
+

{plan.title}

+

{plan.description}

+
+
+ + 共 {plan.total_steps} 个分析步骤 + + {plan.estimated_time_seconds && ( + + 预计 {Math.ceil(plan.estimated_time_seconds / 60)} 分钟 + + )} +
+
+ + {isExecuting && ( +
+
+
+
+ + {completedSteps}/{plan.total_steps} 完成 + +
+ )} + +
+ {plan.steps.map((step, index) => ( + + ))} +
+ + {!isExecuting && stepResults.length === 0 && ( +
+ ✨ 分析计划已就绪,点击「开始分析」执行 +
+ )} +
+ ); +}; + +export default WorkflowTimeline; diff --git a/frontend-v2/src/modules/ssa/components/index.ts b/frontend-v2/src/modules/ssa/components/index.ts index 8991fbf6..a85b24a2 100644 --- a/frontend-v2/src/modules/ssa/components/index.ts +++ b/frontend-v2/src/modules/ssa/components/index.ts @@ -11,3 +11,10 @@ export { default as SSAWorkspacePane } from './SSAWorkspacePane'; export { default as SSACodeModal } from './SSACodeModal'; export { default as SSAToast } from './SSAToast'; export { default as TypeWriter } from './TypeWriter'; + +// Phase 2A: 多步骤工作流组件 +export { DataProfileCard } from './DataProfileCard'; +export { DataProfileModal } from './DataProfileModal'; +export { WorkflowTimeline } from './WorkflowTimeline'; +export { StepProgressCard } from './StepProgressCard'; +export { ConclusionReport } from './ConclusionReport'; diff --git a/frontend-v2/src/modules/ssa/hooks/index.ts b/frontend-v2/src/modules/ssa/hooks/index.ts index 080f91fd..04d64f45 100644 --- a/frontend-v2/src/modules/ssa/hooks/index.ts +++ b/frontend-v2/src/modules/ssa/hooks/index.ts @@ -1,2 +1,3 @@ export { useAnalysis } from './useAnalysis'; export { useArtifactParser, parseArtifactMarkers } from './useArtifactParser'; +export { useWorkflow } from './useWorkflow'; diff --git a/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts b/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts index 3b8a4c88..94fa6c2a 100644 --- a/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts +++ b/frontend-v2/src/modules/ssa/hooks/useAnalysis.ts @@ -327,6 +327,11 @@ export function useAnalysis(): UseAnalysisReturn { 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) { setError('暂无分析结果可导出'); @@ -563,6 +568,295 @@ export function useAnalysis(): UseAnalysisReturn { URL.revokeObjectURL(url); }, [setError]); + const exportWorkflowReport = async ( + steps: any[], + wfPlan: any, + conclusion: any, + session: any, + mountedFile: any + ) => { + const now = new Date(); + const dateStr = now.toLocaleString('zh-CN'); + const dataFileName = mountedFile?.name || session?.title || '数据文件'; + + const createTableRow = (cells: string[], isHeader = false) => { + return new TableRow({ + children: cells.map(text => new TableCell({ + children: [new Paragraph({ + children: [new TextRun({ text: String(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(wfPlan?.title || session?.title || '统计分析'), + ]}), + new Paragraph({ children: [ + new TextRun({ text: '数据文件:', bold: true }), + new TextRun(dataFileName), + ]}), + new Paragraph({ children: [ + new TextRun({ text: '生成时间:', bold: true }), + new TextRun(dateStr), + ]}), + new Paragraph({ children: [ + new TextRun({ text: '分析步骤:', bold: true }), + new TextRun(`共 ${steps.length} 个步骤`), + ]}), + new Paragraph({ text: '' }), + ); + + if (conclusion?.executive_summary) { + sections.push( + new Paragraph({ text: `${sectionNum++}. 摘要`, heading: HeadingLevel.HEADING_1 }), + new Paragraph({ text: conclusion.executive_summary }), + new Paragraph({ text: '' }), + ); + } + + const successSteps = steps.filter(s => s.status === 'success' && s.result); + for (const step of successSteps) { + const r = step.result as any; + sections.push( + new Paragraph({ text: `${sectionNum++}. 步骤 ${step.step_number}: ${step.tool_name}`, heading: HeadingLevel.HEADING_1 }), + ); + + if (step.duration_ms) { + sections.push(new Paragraph({ children: [ + new TextRun({ text: `执行耗时:${step.duration_ms}ms`, italics: true, color: '666666' }), + ]})); + } + + const isDescStep = step.tool_code === 'ST_DESCRIPTIVE' || r?.summary; + + if (isDescStep && (r?.summary || r?.variables)) { + if (r?.summary) { + sections.push( + new Paragraph({ text: `总观测数: ${r.summary.n_total ?? '-'}, 分析变量数: ${r.summary.n_variables ?? '-'}, 数值变量: ${r.summary.n_numeric ?? '-'}, 分类变量: ${r.summary.n_categorical ?? '-'}` }), + new Paragraph({ text: '' }), + ); + } + + if (r?.variables && typeof r.variables === 'object') { + const classifyExportVar = (v: any): 'numeric' | 'categorical' | 'unknown' => { + if (!v) return 'unknown'; + if (v.type === 'numeric') return 'numeric'; + if (v.type === 'categorical') return 'categorical'; + if (v.mean !== undefined) return 'numeric'; + if (v.levels && Array.isArray(v.levels)) return 'categorical'; + if (v.by_group) { + const first = Object.values(v.by_group)[0] as any; + if (first?.mean !== undefined) return 'numeric'; + if (first?.levels) return 'categorical'; + } + 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) { + const vs = rawVs as any; + if (vs.by_group) { + for (const [gName, gStats] of Object.entries(vs.by_group)) { + const g = gStats as any; + numRows.push(createTableRow([ + `${vs.variable || varName} [${gName}]`, String(g.n ?? '-'), + g.formatted || (g.mean !== undefined ? `${g.mean} ± ${g.sd}` : '-'), + String(g.median ?? '-'), String(g.q1 ?? '-'), String(g.q3 ?? '-'), + String(g.min ?? '-'), String(g.max ?? '-'), + ])); + } + } else { + numRows.push(createTableRow([ + vs.variable || varName, String(vs.n ?? '-'), + vs.formatted || (vs.mean !== undefined ? `${vs.mean} ± ${vs.sd}` : '-'), + String(vs.median ?? '-'), String(vs.q1 ?? '-'), String(vs.q3 ?? '-'), + String(vs.min ?? '-'), String(vs.max ?? '-'), + ])); + } + } + sections.push( + new Paragraph({ text: '数值变量统计', heading: HeadingLevel.HEADING_2 }), + new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: numRows }), + new Paragraph({ text: '' }), + ); + } + + if (catVars.length > 0) { + sections.push(new Paragraph({ text: '分类变量统计', heading: HeadingLevel.HEADING_2 })); + for (const [varName, rawVs] of catVars) { + const vs = rawVs as any; + if (vs.by_group) { + for (const [gName, gStats] of Object.entries(vs.by_group)) { + const g = gStats as any; + const levels = g.levels || []; + if (levels.length > 0) { + sections.push( + new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName} [${gName}]`, bold: true }), new TextRun(` (N=${g.n ?? '-'})`)] }), + new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [ + createTableRow(['类别', '频数', '百分比'], true), + ...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])), + ]}), + new Paragraph({ text: '' }), + ); + } + } + } else { + const levels = vs.levels || []; + if (levels.length > 0) { + sections.push( + new Paragraph({ children: [new TextRun({ text: `${vs.variable || varName}`, bold: true }), new TextRun(` (N=${vs.n ?? '-'}, 缺失=${vs.missing ?? 0})`)] }), + new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, borders: tableBorders, rows: [ + createTableRow(['类别', '频数', '百分比'], true), + ...levels.map((lv: any) => createTableRow([String(lv.level), String(lv.n), `${lv.pct}%`])), + ]}), + new Paragraph({ text: '' }), + ); + } + } + } + } + } + } else { + const statsRows = [ + createTableRow(['指标', '值'], true), + createTableRow(['统计方法', r?.method || step.tool_name]), + ]; + 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' + ? 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: '' })); + } + + if (r?.group_stats?.length > 0) { + sections.push( + new Paragraph({ text: '分组统计', heading: HeadingLevel.HEADING_2 }), + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: tableBorders, + rows: [ + createTableRow(['分组', 'N', '均值', '标准差'], true), + ...r.group_stats.map((g: any) => createTableRow([ + String(g.group), String(g.n), + g.mean !== undefined ? Number(g.mean).toFixed(4) : '-', + g.sd !== undefined ? Number(g.sd).toFixed(4) : '-', + ])), + ], + }), + new Paragraph({ text: '' }), + ); + } + + if (r?.coefficients && Array.isArray(r.coefficients) && r.coefficients.length > 0) { + sections.push( + new Paragraph({ text: '回归系数', heading: HeadingLevel.HEADING_2 }), + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: tableBorders, + rows: [ + createTableRow(['变量', '估计值', 'OR', '95% CI', 'P 值'], true), + ...r.coefficients.map((c: any) => createTableRow([ + c.variable || c.term || '-', + Number(c.estimate || c.coef || 0).toFixed(4), + c.OR !== undefined ? Number(c.OR).toFixed(4) : '-', + c.ci_lower !== undefined ? `[${Number(c.ci_lower).toFixed(3)}, ${Number(c.ci_upper).toFixed(3)}]` : '-', + c.p_value_fmt || (c.p_value !== undefined ? (c.p_value < 0.001 ? '< 0.001' : Number(c.p_value).toFixed(4)) : '-'), + ])), + ], + }), + new Paragraph({ text: '' }), + ); + } + + if (r?.plots?.length > 0) { + for (const plot of r.plots) { + const imageBase64 = typeof plot === 'string' ? plot : plot.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({ + children: [new ImageRun({ data: imageBuffer, transformation: { width: 450, height: 300 }, type: 'png' })], + alignment: AlignmentType.CENTER, + }), + new Paragraph({ text: '' }), + ); + } catch (e) { /* skip */ } + } + } + } + } + + if (conclusion?.key_findings?.length > 0) { + sections.push( + new Paragraph({ text: `${sectionNum++}. 主要发现`, heading: HeadingLevel.HEADING_1 }), + ...conclusion.key_findings.map((f: string) => new Paragraph({ text: `• ${f}` })), + 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}` })), + new Paragraph({ text: '' }), + ); + } + + sections.push( + new Paragraph({ text: '' }), + new Paragraph({ children: [ + 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' }), + ]}), + ); + + 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); + }; + return { uploadData, generatePlan, diff --git a/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts b/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts new file mode 100644 index 00000000..77b33025 --- /dev/null +++ b/frontend-v2/src/modules/ssa/hooks/useWorkflow.ts @@ -0,0 +1,376 @@ +/** + * 多步骤工作流 Hook + * + * Phase 2A: 处理数据画像、工作流规划、SSE 执行等 + */ +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, + WorkflowStepResult, + SSEMessage, + SSAMessage, +} from '../types'; + +const API_BASE = '/api/v1/ssa'; + +interface UseWorkflowReturn { + generateDataProfile: (sessionId: string) => Promise; + generateWorkflowPlan: (sessionId: string, query: string) => Promise; + executeWorkflow: (sessionId: string, workflowId: string) => Promise; + cancelWorkflow: () => void; + isProfileLoading: boolean; + isPlanLoading: boolean; + isExecuting: boolean; +} + +export function useWorkflow(): UseWorkflowReturn { + const { + setDataProfile, + setDataProfileLoading, + dataProfileLoading, + setWorkflowPlan, + setWorkflowPlanLoading, + workflowPlanLoading, + setWorkflowSteps, + updateWorkflowStep, + setWorkflowProgress, + setConclusionReport, + setIsWorkflowMode, + setActivePane, + setWorkspaceOpen, + addMessage, + setExecuting, + isExecuting, + setError, + addToast, + } = useSSAStore(); + + const abortControllerRef = useRef(null); + const eventSourceRef = useRef(null); + + 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 = { + id: crypto.randomUUID(), + role: 'assistant', + content: `数据质量核查完成:${profile.quality_grade}级 (${profile.quality_score}分)`, + artifactType: 'sap', + createdAt: new Date().toISOString(), + }; + addMessage(profileMessage); + + return profile; + } catch (error: any) { + const errorMsg = error.response?.data?.message || error.message || '数据画像生成失败'; + setError(errorMsg); + addToast(errorMsg, 'error'); + throw error; + } finally { + setDataProfileLoading(false); + } + }, [setDataProfile, setDataProfileLoading, setError, addMessage, addToast]); + + const generateWorkflowPlan = useCallback(async ( + 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 plan: WorkflowPlan = response.data.plan; + + setWorkflowPlan(plan); + setActivePane('sap'); + setWorkspaceOpen(true); + + const planMessage: SSAMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: `已生成分析方案:${plan.title}\n共 ${plan.total_steps} 个分析步骤`, + artifactType: 'sap', + createdAt: new Date().toISOString(), + }; + addMessage(planMessage); + + const confirmMessage: SSAMessage = { + 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 || '工作流规划失败'; + setError(errorMsg); + addToast(errorMsg, 'error'); + throw error; + } finally { + setWorkflowPlanLoading(false); + } + }, [ + setWorkflowPlan, + setWorkflowPlanLoading, + setIsWorkflowMode, + setActivePane, + setWorkspaceOpen, + addMessage, + setError, + addToast + ]); + + const executeWorkflow = useCallback(async ( + sessionId: string, + workflowId: string + ): Promise => { + setExecuting(true); + setActivePane('execution'); + setWorkflowSteps([]); + setWorkflowProgress(0); + setConclusionReport(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', + }, + signal: abortControllerRef.current.signal, + }).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 decoder = new TextDecoder(); + let buffer = ''; + + 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 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 = { + step_number: stepNumber, + tool_code: toolCode, + tool_name: toolName, + status: 'running', + 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]); + } + } + 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), + }); + } + break; + + case 'step_complete': + if (stepNumber !== undefined) { + const result = message.result || message.data?.result; + const durationMs = message.duration_ms || message.durationMs || message.data?.duration_ms; + + updateWorkflowStep(stepNumber, { + status: message.status || message.data?.status || 'success', + completed_at: new Date().toISOString(), + duration_ms: durationMs, + result: result, + }); + + 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, + }); + } + 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); + } + + 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; + } + }; + + 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); + } + setExecuting(false); + }); + }); + }, [ + setExecuting, + setActivePane, + setWorkflowSteps, + setWorkflowProgress, + setConclusionReport, + updateWorkflowStep, + addMessage, + setError, + addToast + ]); + + const cancelWorkflow = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setExecuting(false); + }, [setExecuting]); + + return { + generateDataProfile, + generateWorkflowPlan, + executeWorkflow, + cancelWorkflow, + isProfileLoading: dataProfileLoading, + isPlanLoading: workflowPlanLoading, + isExecuting, + }; +} + +export default useWorkflow; diff --git a/frontend-v2/src/modules/ssa/stores/ssaStore.ts b/frontend-v2/src/modules/ssa/stores/ssaStore.ts index 0bab7ad2..fbd936b5 100644 --- a/frontend-v2/src/modules/ssa/stores/ssaStore.ts +++ b/frontend-v2/src/modules/ssa/stores/ssaStore.ts @@ -12,6 +12,10 @@ import type { AnalysisPlan, ExecutionResult, TraceStep, + DataProfile, + WorkflowPlan, + WorkflowStepResult, + ConclusionReport, } from '../types'; type ArtifactPane = 'empty' | 'sap' | 'execution' | 'result'; @@ -61,6 +65,17 @@ interface SSAState { 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; // 是否使用多步骤工作流模式 + setMode: (mode: SSAMode) => void; setCurrentSession: (session: SSASession | null) => void; addMessage: (message: SSAMessage) => void; @@ -89,6 +104,19 @@ interface SSAState { updateAnalysisRecord: (id: string, update: Partial>) => void; selectAnalysisRecord: (id: string) => void; getCurrentRecord: () => AnalysisRecord | null; + + // Phase 2A: 多步骤工作流操作 + 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 = { @@ -109,6 +137,16 @@ 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) => ({ @@ -266,6 +304,44 @@ export const useSSAStore = create((set) => ({ 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) => + set((state) => ({ + workflowSteps: state.workflowSteps.map((s) => + s.step_number === stepNumber ? { ...s, ...update } : s + ), + })), + + setWorkflowProgress: (progress) => set({ workflowProgress: progress }), + + setConclusionReport: (report) => set({ conclusionReport: report }), + + setIsWorkflowMode: (isWorkflow) => set({ isWorkflowMode: isWorkflow }), + + resetWorkflow: () => + set({ + dataProfile: null, + dataProfileLoading: false, + workflowPlan: null, + workflowPlanLoading: false, + workflowSteps: [], + workflowProgress: 0, + conclusionReport: null, + isWorkflowMode: false, + }), })); 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 685066f5..22edcb90 100644 --- a/frontend-v2/src/modules/ssa/styles/ssa-workspace.css +++ b/frontend-v2/src/modules/ssa/styles/ssa-workspace.css @@ -1094,7 +1094,7 @@ /* 执行完成的折叠日志 */ .view-execution-completed { - padding: 16px; + padding: 0; } .execution-completed-header { @@ -1304,14 +1304,10 @@ /* Execution View */ .view-execution { - position: absolute; - inset: 0; background: white; - padding: 32px; display: flex; flex-direction: column; align-items: center; - justify-content: center; } .execution-header { @@ -1329,17 +1325,16 @@ .terminal-box { width: 100%; - max-width: 512px; background-color: #0f172a; border-radius: 12px; padding: 20px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; font-size: 12px; color: #cbd5e1; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid #334155; position: relative; - min-height: 256px; + min-height: 120px; overflow: hidden; } @@ -1412,14 +1407,12 @@ /* Error View */ .view-error { - position: absolute; - inset: 0; background: white; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 32px; + padding: 16px 0; text-align: center; } @@ -1508,7 +1501,6 @@ /* Result View */ .view-result { - padding: 32px; width: 100%; } @@ -2093,3 +2085,1589 @@ .text-red-600 { color: #dc2626; } .text-amber-400 { color: #fbbf24; } .text-slate-400 { color: #94a3b8; } + +/* ============================================ + Phase 2A: 数据质量报告卡片样式 + ============================================ */ + +.ssa-profile-card-compact { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid #e2e8f0; + border-radius: 12px; + animation: fadeIn 0.3s ease; +} + +.ssa-profile-card-compact .profile-header { + display: flex; + align-items: center; + gap: 8px; +} + +.ssa-profile-card-compact .profile-icon { + font-size: 16px; +} + +.ssa-profile-card-compact .profile-title { + font-size: 14px; + font-weight: 500; + color: #334155; +} + +.ssa-profile-card-compact .quality-badge { + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.ssa-profile-card-compact .profile-metrics { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #64748b; +} + +.ssa-profile-card-compact .separator { + color: #cbd5e1; +} + +.ssa-profile-card-compact .warning-count { + color: #d97706; +} + +.ssa-profile-card-compact .view-details-btn { + align-self: flex-start; + padding: 4px 12px; + background: transparent; + border: 1px solid #2563eb; + border-radius: 6px; + color: #2563eb; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.ssa-profile-card-compact .view-details-btn:hover { + background: #2563eb; + color: white; +} + +.ssa-profile-card { + background: white; + border: 1px solid #e2e8f0; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + animation: slideUp 0.3s ease; +} + +.ssa-profile-card .card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid #e2e8f0; +} + +.ssa-profile-card .header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.ssa-profile-card .card-icon { + font-size: 20px; +} + +.ssa-profile-card .card-title { + font-size: 16px; + font-weight: 600; + color: #1e293b; +} + +.ssa-profile-card .quality-score-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; +} + +.ssa-profile-card .quality-score-badge .grade { + font-size: 16px; + font-weight: 700; +} + +.ssa-profile-card .quality-score-badge .score { + font-size: 12px; + font-weight: 500; +} + +.ssa-profile-card .card-body { + padding: 20px; +} + +.ssa-profile-card .metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 16px; +} + +.ssa-profile-card .metric-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ssa-profile-card .metric-label { + font-size: 12px; + color: #64748b; +} + +.ssa-profile-card .metric-value { + font-size: 14px; + font-weight: 500; + color: #1e293b; +} + +.ssa-profile-card .warnings-section, +.ssa-profile-card .recommendations-section { + margin-top: 16px; + padding: 12px; + border-radius: 8px; +} + +.ssa-profile-card .warnings-section { + background: #fffbeb; + border: 1px solid #fde68a; +} + +.ssa-profile-card .recommendations-section { + background: #eff6ff; + border: 1px solid #bfdbfe; +} + +.ssa-profile-card .warnings-header, +.ssa-profile-card .recommendations-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: #1e293b; + margin-bottom: 8px; +} + +.ssa-profile-card .warnings-list, +.ssa-profile-card .recommendations-list { + margin: 0; + padding-left: 20px; + font-size: 12px; + color: #64748b; +} + +.ssa-profile-card .warnings-list li, +.ssa-profile-card .recommendations-list li { + margin-bottom: 4px; +} + +.ssa-profile-card .more-warnings { + color: #d97706; + font-style: italic; +} + +.ssa-profile-card .card-footer { + padding: 12px 20px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + text-align: right; +} + +.ssa-profile-card .view-details-btn { + padding: 8px 16px; + background: #2563eb; + border: none; + border-radius: 8px; + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.ssa-profile-card .view-details-btn:hover { + background: #1d4ed8; + transform: translateY(-1px); +} + +/* ============================================ + Phase 2A: 数据质量报告模态框样式 + ============================================ */ + +.ssa-profile-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +.ssa-profile-modal { + width: 90%; + max-width: 900px; + max-height: 85vh; + background: white; + border-radius: 16px; + overflow: hidden; + display: flex; + flex-direction: column; + animation: popIn 0.3s ease; +} + +.ssa-profile-modal .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + background: linear-gradient(135deg, #1e293b 0%, #334155 100%); + color: white; +} + +.ssa-profile-modal .header-content h2 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.ssa-profile-modal .file-name { + font-size: 13px; + color: #94a3b8; + margin-top: 4px; + display: block; +} + +.ssa-profile-modal .close-btn { + width: 32px; + height: 32px; + background: rgba(255,255,255,0.1); + border: none; + border-radius: 8px; + color: white; + font-size: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.ssa-profile-modal .close-btn:hover { + background: rgba(255,255,255,0.2); +} + +.ssa-profile-modal .modal-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.ssa-profile-modal .overview-section { + display: flex; + gap: 32px; + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid #e2e8f0; +} + +.ssa-profile-modal .quality-overview { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.ssa-profile-modal .quality-circle { + width: 80px; + height: 80px; + border-radius: 50%; + border: 3px solid; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.ssa-profile-modal .quality-circle .grade { + font-size: 28px; + font-weight: 700; +} + +.ssa-profile-modal .quality-circle .score { + font-size: 12px; + font-weight: 500; +} + +.ssa-profile-modal .quality-label { + font-size: 14px; + font-weight: 500; + color: #64748b; +} + +.ssa-profile-modal .overview-metrics { + flex: 1; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 16px; +} + +.ssa-profile-modal .metric-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px; + background: #f8fafc; + border-radius: 12px; +} + +.ssa-profile-modal .metric-card .metric-icon { + font-size: 20px; +} + +.ssa-profile-modal .metric-card .metric-value { + font-size: 18px; + font-weight: 600; + color: #1e293b; +} + +.ssa-profile-modal .metric-card .metric-label { + font-size: 11px; + color: #64748b; +} + +.ssa-profile-modal .type-distribution { + margin-bottom: 24px; +} + +.ssa-profile-modal .type-distribution h3 { + font-size: 14px; + font-weight: 600; + color: #1e293b; + margin: 0 0 16px 0; +} + +.ssa-profile-modal .type-bars { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ssa-profile-modal .type-bar { + display: flex; + align-items: center; + gap: 12px; +} + +.ssa-profile-modal .type-bar .type-label { + width: 100px; + font-size: 13px; + color: #64748b; +} + +.ssa-profile-modal .bar-wrapper { + flex: 1; + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; +} + +.ssa-profile-modal .bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.ssa-profile-modal .bar-fill.numeric { + background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); +} + +.ssa-profile-modal .bar-fill.categorical { + background: linear-gradient(90deg, #8b5cf6 0%, #7c3aed 100%); +} + +.ssa-profile-modal .bar-fill.datetime { + background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); +} + +.ssa-profile-modal .type-count { + width: 30px; + text-align: right; + font-size: 13px; + font-weight: 500; + color: #1e293b; +} + +.ssa-profile-modal .alerts-section { + display: flex; + gap: 16px; + margin-bottom: 24px; +} + +.ssa-profile-modal .alert-box { + flex: 1; + padding: 16px; + border-radius: 12px; +} + +.ssa-profile-modal .alert-box.warning { + background: #fffbeb; + border: 1px solid #fde68a; +} + +.ssa-profile-modal .alert-box.info { + background: #eff6ff; + border: 1px solid #bfdbfe; +} + +.ssa-profile-modal .alert-box h4 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + color: #1e293b; +} + +.ssa-profile-modal .alert-box ul { + margin: 0; + padding-left: 20px; + font-size: 12px; + color: #64748b; +} + +.ssa-profile-modal .alert-box li { + margin-bottom: 6px; +} + +.ssa-profile-modal .columns-section h3 { + font-size: 14px; + font-weight: 600; + color: #1e293b; + margin: 0 0 16px 0; +} + +.ssa-profile-modal .columns-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.column-detail-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 16px; +} + +.column-detail-card .column-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #e2e8f0; +} + +.column-detail-card .type-icon { + font-size: 16px; +} + +.column-detail-card .column-name { + font-size: 14px; + font-weight: 600; + color: #1e293b; + flex: 1; +} + +.column-detail-card .column-type { + font-size: 11px; + padding: 2px 8px; + background: #e2e8f0; + border-radius: 10px; + color: #64748b; +} + +.column-detail-card .column-stats { + display: flex; + flex-direction: column; + gap: 8px; +} + +.column-detail-card .stat-row { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.column-detail-card .stat-row .stat-label { + color: #64748b; +} + +.column-detail-card .stat-row .stat-value { + color: #1e293b; + font-weight: 500; +} + +.column-detail-card .stat-row.warning .stat-value { + color: #d97706; +} + +.column-detail-card .categories-section { + margin-top: 12px; +} + +.column-detail-card .category-bars { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.column-detail-card .category-bar { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + position: relative; +} + +.column-detail-card .category-bar .bar-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background: #dbeafe; + border-radius: 4px; + z-index: 0; +} + +.column-detail-card .category-bar .bar-label { + flex: 1; + color: #1e293b; + z-index: 1; + padding: 2px 6px; +} + +.column-detail-card .category-bar .bar-count { + color: #64748b; + z-index: 1; +} + +.column-detail-card .sample-values { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed #e2e8f0; + font-size: 11px; +} + +.column-detail-card .sample-label { + color: #64748b; + margin-right: 6px; +} + +.column-detail-card .sample-content { + color: #94a3b8; + font-family: 'SF Mono', Monaco, monospace; +} + +.ssa-profile-modal .modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +.ssa-profile-modal .generated-time { + font-size: 12px; + color: #94a3b8; +} + +.ssa-profile-modal .close-footer-btn { + padding: 8px 20px; + background: #1e293b; + border: none; + border-radius: 8px; + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.ssa-profile-modal .close-footer-btn:hover { + background: #334155; +} + +/* ============================================ + Phase 2A: 工作流时间线样式 + ============================================ */ + +.workflow-timeline { + padding: 20px; +} + +.workflow-timeline .timeline-header { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #e2e8f0; +} + +.workflow-timeline .header-info { + margin-bottom: 12px; +} + +.workflow-timeline .timeline-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #1e293b; + line-height: 1.4; +} + +.workflow-timeline .timeline-description { + margin: 8px 0 0 0; + font-size: 13px; + color: #64748b; + line-height: 1.6; + word-break: break-word; + overflow-wrap: break-word; +} + +.workflow-timeline .header-meta { + display: flex; + gap: 16px; + margin-top: 0; +} + +.workflow-timeline .step-count, +.workflow-timeline .estimated-time { + font-size: 12px; + color: #94a3b8; + display: flex; + align-items: center; + gap: 4px; +} + +.workflow-timeline .timeline-progress { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + padding: 12px 16px; + background: #f8fafc; + border-radius: 8px; +} + +.workflow-timeline .progress-bar { + flex: 1; + height: 6px; + background: #e2e8f0; + border-radius: 3px; + overflow: hidden; +} + +.workflow-timeline .progress-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); + border-radius: 3px; + transition: width 0.3s ease; +} + +.workflow-timeline .progress-text { + font-size: 12px; + font-weight: 500; + color: #64748b; + white-space: nowrap; +} + +.workflow-timeline .timeline-steps { + display: flex; + flex-direction: column; +} + +.workflow-timeline .timeline-step { + display: flex; + gap: 16px; + padding-bottom: 20px; +} + +.workflow-timeline .timeline-step.current { + background: #eff6ff; + margin: 0 -20px; + padding: 16px 20px; + border-radius: 8px; +} + +.workflow-timeline .step-connector { + display: flex; + flex-direction: column; + align-items: center; + width: 24px; +} + +.workflow-timeline .step-dot { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; +} + +.workflow-timeline .step-dot.pulse { + animation: pulse 1.5s ease-in-out infinite; +} + +.workflow-timeline .step-line { + flex: 1; + width: 2px; + border-left: 2px dashed #e2e8f0; + margin-top: 4px; +} + +.workflow-timeline .step-content { + flex: 1; + min-width: 0; +} + +.workflow-timeline .step-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.workflow-timeline .step-number { + font-size: 12px; + font-weight: 500; + color: #64748b; +} + +.workflow-timeline .tool-icon { + font-size: 14px; +} + +.workflow-timeline .tool-name { + font-size: 14px; + font-weight: 600; + color: #1e293b; +} + +.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 .ready-hint { + font-size: 13px; + color: #64748b; +} + +/* ============================================ + Phase 2A: 步骤进度卡片样式 + ============================================ */ + +.step-progress-card { + border-left: 3px solid; + background: white; + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); + transition: all 0.2s; +} + +.step-progress-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.step-progress-card.running { + background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%); +} + +.step-progress-card .card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + cursor: pointer; + transition: background 0.2s; +} + +.step-progress-card .card-header:hover { + background: rgba(0,0,0,0.02); +} + +.step-progress-card .header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.step-progress-card .status-icon { + font-size: 16px; +} + +.step-progress-card .status-icon.spin { + animation: spin 1s linear infinite; +} + +.step-progress-card .step-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.step-progress-card .step-number { + font-size: 11px; + color: #94a3b8; +} + +.step-progress-card .tool-name { + font-size: 14px; + font-weight: 500; + color: #1e293b; +} + +.step-progress-card .header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.step-progress-card .status-badge { + padding: 2px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.step-progress-card .duration { + font-size: 11px; + color: #94a3b8; + font-family: 'SF Mono', Monaco, monospace; +} + +.step-progress-card .expand-icon { + font-size: 10px; + color: #94a3b8; + transition: transform 0.2s; +} + +.step-progress-card .expand-icon.rotated { + transform: rotate(180deg); +} + +.step-progress-card .card-body { + padding: 0 16px 16px; + border-top: 1px solid #f1f5f9; + animation: slideDown 0.2s ease; +} + +@keyframes slideDown { + from { opacity: 0; max-height: 0; } + to { opacity: 1; max-height: 500px; } +} + +.step-progress-card .time-info { + display: flex; + gap: 16px; + padding: 12px 0; + font-size: 11px; + color: #94a3b8; +} + +.step-progress-card .logs-section { + margin-bottom: 12px; +} + +.step-progress-card .logs-header { + font-size: 12px; + font-weight: 500; + color: #64748b; + margin-bottom: 8px; +} + +.step-progress-card .logs-content { + background: #1e293b; + border-radius: 8px; + padding: 12px; + max-height: 150px; + overflow-y: auto; +} + +.step-progress-card .log-line { + display: flex; + gap: 8px; + font-size: 11px; + font-family: 'SF Mono', Monaco, monospace; + color: #94a3b8; + margin-bottom: 4px; +} + +.step-progress-card .log-prefix { + color: #3b82f6; +} + +.step-progress-card .result-preview { + background: #f8fafc; + border-radius: 8px; + padding: 12px; +} + +.step-progress-card .result-header { + font-size: 12px; + font-weight: 500; + color: #64748b; + margin-bottom: 8px; +} + +.step-progress-card .result-content { + display: flex; + flex-direction: column; + gap: 6px; +} + +.step-progress-card .result-row { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.step-progress-card .result-row .label { + color: #64748b; +} + +.step-progress-card .result-row .value { + color: #1e293b; + font-weight: 500; +} + +.step-progress-card .interpretation { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed #e2e8f0; + font-size: 12px; + color: #64748b; + line-height: 1.5; +} + +.step-progress-card .error-section { + background: #fef2f2; + border-radius: 8px; + padding: 12px; +} + +.step-progress-card .error-header { + font-size: 12px; + font-weight: 500; + color: #dc2626; + margin-bottom: 8px; +} + +.step-progress-card .error-content { + font-size: 12px; + color: #991b1b; +} + +/* ============================================ + Phase 2A: 综合结论报告样式 + ============================================ */ + +.conclusion-report { + padding: 24px; + animation: fadeIn 0.3s ease; +} + +.conclusion-report .report-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +} + +.conclusion-report .report-title { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #1e293b; +} + +.conclusion-report .generated-time { + font-size: 12px; + color: #94a3b8; +} + +.conclusion-report .executive-summary { + background: linear-gradient(135deg, #eff6ff 0%, #f0fdf4 100%); + border: 1px solid #bfdbfe; + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; +} + +.conclusion-report .summary-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.conclusion-report .summary-icon { + font-size: 20px; +} + +.conclusion-report .summary-label { + font-size: 14px; + font-weight: 600; + color: #1e293b; +} + +.conclusion-report .summary-content { + font-size: 14px; + line-height: 1.7; + color: #334155; +} + +.conclusion-report .key-findings { + margin-bottom: 24px; +} + +.conclusion-report .section-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.conclusion-report .section-icon { + font-size: 16px; +} + +.conclusion-report .section-title { + font-size: 15px; + font-weight: 600; + color: #1e293b; +} + +.conclusion-report .findings-list { + margin: 0; + padding-left: 24px; +} + +.conclusion-report .findings-list li { + font-size: 14px; + color: #475569; + margin-bottom: 8px; + line-height: 1.5; +} + +.conclusion-report .stats-overview { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.conclusion-report .stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 20px; + background: #f8fafc; + border-radius: 12px; + transition: all 0.2s; +} + +.conclusion-report .stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.05); +} + +.conclusion-report .stat-card.significant { + background: linear-gradient(135deg, #ecfdf5 0%, #f0fdf4 100%); + border: 1px solid #a7f3d0; +} + +.conclusion-report .stat-card .stat-icon { + font-size: 24px; +} + +.conclusion-report .stat-card .stat-value { + font-size: 28px; + font-weight: 700; + color: #1e293b; +} + +.conclusion-report .stat-card .stat-label { + font-size: 12px; + color: #64748b; +} + +.conclusion-report .toggle-details-btn { + width: 100%; + padding: 12px; + background: transparent; + border: 1px solid #e2e8f0; + border-radius: 8px; + color: #64748b; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 24px; +} + +.conclusion-report .toggle-details-btn:hover { + background: #f8fafc; + color: #1e293b; +} + +.conclusion-report .step-results-section { + margin-bottom: 24px; +} + +.conclusion-report .step-results-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.step-result-detail { + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + transition: all 0.2s; +} + +.step-result-detail.expanded { + box-shadow: 0 4px 12px rgba(0,0,0,0.05); +} + +.step-result-detail .detail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + background: #f8fafc; + cursor: pointer; + transition: background 0.2s; +} + +.step-result-detail .detail-header:hover { + background: #f1f5f9; +} + +.step-result-detail .header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.step-result-detail .step-badge { + padding: 2px 8px; + background: #e2e8f0; + border-radius: 4px; + font-size: 11px; + color: #64748b; + font-weight: 500; +} + +.step-result-detail .tool-name { + font-size: 14px; + font-weight: 500; + color: #1e293b; +} + +.step-result-detail .p-value-badge { + padding: 2px 10px; + border-radius: 12px; + font-size: 11px; + font-family: 'SF Mono', Monaco, monospace; +} + +.step-result-detail .expand-arrow { + font-size: 10px; + color: #94a3b8; + transition: transform 0.2s; +} + +.step-result-detail .expand-arrow.rotated { + transform: rotate(180deg); +} + +.step-result-detail .detail-summary { + padding: 12px 16px; + font-size: 13px; + color: #64748b; + border-top: 1px solid #f1f5f9; +} + +.step-result-detail .detail-content { + padding: 16px; + border-top: 1px solid #e2e8f0; + animation: fadeIn 0.2s ease; +} + +.step-result-detail .result-table-wrapper { + overflow-x: auto; + margin-bottom: 16px; +} + +.step-result-detail .result-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.step-result-detail .result-table th, +.step-result-detail .result-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #e2e8f0; +} + +.step-result-detail .result-table th { + background: #f8fafc; + font-weight: 600; + color: #475569; +} + +.step-result-detail .result-table td { + color: #64748b; +} + +.step-result-detail .result-plots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.step-result-detail .plot-item { + text-align: center; +} + +.step-result-detail .plot-title { + font-size: 12px; + font-weight: 500; + color: #64748b; + margin-bottom: 8px; +} + +.step-result-detail .plot-image { + max-width: 100%; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.step-result-detail .interpretation-box { + background: #fffbeb; + border-radius: 8px; + padding: 12px; +} + +.step-result-detail .interpretation-label { + font-size: 12px; + font-weight: 500; + color: #92400e; + display: block; + margin-bottom: 6px; +} + +.step-result-detail .interpretation-box p { + margin: 0; + font-size: 13px; + color: #78350f; + line-height: 1.5; +} + +.conclusion-report .recommendations-section, +.conclusion-report .limitations-section { + margin-bottom: 24px; +} + +.conclusion-report .recommendations-list, +.conclusion-report .limitations-list { + margin: 0; + padding-left: 24px; +} + +.conclusion-report .recommendations-list li, +.conclusion-report .limitations-list li { + font-size: 13px; + color: #64748b; + margin-bottom: 6px; + line-height: 1.5; +} + +.conclusion-report .methods-used { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding-top: 20px; + border-top: 1px solid #e2e8f0; +} + +.conclusion-report .methods-label { + font-size: 12px; + color: #94a3b8; +} + +.conclusion-report .methods-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.conclusion-report .method-tag { + padding: 4px 10px; + background: #f1f5f9; + border-radius: 6px; + font-size: 11px; + color: #64748b; +} + +/* ============================================ + Phase 2A: 额外补充样式 + ============================================ */ + +/* 数据画像加载状态 */ +.profile-loading { + display: flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; +} + +.profile-bubble { + padding: 0 !important; + background: transparent !important; + border: none !important; +} + +/* 取消按钮 */ +.cancel-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #dc2626; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.cancel-btn:hover { + background: #fee2e2; + border-color: #f87171; +} + +/* 已完成的执行按钮 */ +.run-btn.completed { + background: #ecfdf5; + border-color: #a7f3d0; + color: #059669; + cursor: default; +} + +/* 工作流执行头部 */ +.execution-header.workflow-header { + flex-wrap: wrap; + gap: 12px; +} + +.workflow-progress-bar { + flex: 1; + min-width: 100px; + height: 6px; + background: #e2e8f0; + border-radius: 3px; + overflow: hidden; +} + +.workflow-progress-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); + border-radius: 3px; + transition: width 0.3s ease; +} + +.workflow-progress-text { + font-size: 12px; + font-weight: 500; + color: #64748b; +} + +/* 工作流步骤列表 */ +.workflow-steps-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; +} + +/* Engine 状态 - 数据画像 */ +.engine-status.status-profiling { + color: #7c3aed; +} + +.engine-status.status-profiling .status-dot { + background: #7c3aed; + animation: pulse 1.5s ease-in-out infinite; +} + +/* ============================================ + Phase 2A: 多步骤结果展示 - 复用 MVP 风格 + ============================================ */ + +.workflow-step-result { + padding: 16px 0; + border-bottom: 1px solid #f1f5f9; +} + +.workflow-step-result:last-child { + border-bottom: none; +} + +.workflow-step-result .table-label { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 12px 0; + padding-bottom: 8px; + border-bottom: 1px solid #e2e8f0; + font-size: 14px; + font-weight: 600; + color: #1e293b; +} + +.workflow-step-result .result-raw-data { + margin-top: 12px; +} + +.workflow-step-result .raw-json { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; + color: #475569; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +.workflow-step-result .step-duration-badge { + font-size: 11px; + font-weight: 400; + color: #94a3b8; + padding: 2px 8px; + background: #f8fafc; + border-radius: 4px; + margin-left: auto; +} diff --git a/frontend-v2/src/modules/ssa/types/index.ts b/frontend-v2/src/modules/ssa/types/index.ts index 74e79660..314b0549 100644 --- a/frontend-v2/src/modules/ssa/types/index.ts +++ b/frontend-v2/src/modules/ssa/types/index.ts @@ -168,3 +168,162 @@ export interface TraceStep { message: string; durationMs?: number; } + +// ============================================ +// Phase 2A: 多步骤工作流类型定义 +// ============================================ + +/** 数据质量等级 */ +export type DataQualityGrade = 'A' | 'B' | 'C' | 'D'; + +/** 列级数据画像 */ +export interface ColumnProfile { + name: string; + dtype: string; + inferred_type: 'numeric' | 'categorical' | 'datetime' | 'text'; + non_null_count: number; + null_count: number; + null_ratio: number; + unique_count: number; + unique_ratio: number; + sample_values: (string | number | null)[]; + // 数值型额外字段 + mean?: number; + std?: number; + min?: number; + max?: number; + median?: number; + q1?: number; + q3?: number; + skewness?: number; + kurtosis?: number; + outlier_count?: number; + outlier_ratio?: number; + // 分类型额外字段 + top_categories?: { value: string; count: number; ratio: number }[]; +} + +/** 数据质量画像 */ +export interface DataProfile { + file_name: string; + row_count: number; + column_count: number; + total_cells: number; + missing_cells: number; + missing_ratio: number; + duplicate_rows: number; + duplicate_ratio: number; + numeric_columns: number; + categorical_columns: number; + datetime_columns: number; + quality_score: number; + quality_grade: DataQualityGrade; + columns: ColumnProfile[]; + warnings: string[]; + recommendations: string[]; + generated_at: string; +} + +/** 工作流步骤定义 */ +export interface WorkflowStepDef { + step_number: number; + tool_code: string; + tool_name: string; + description: string; + params: Record; + depends_on?: number[]; + fallback_tool?: string; +} + +/** 工作流计划 */ +export interface WorkflowPlan { + workflow_id: string; + session_id: string; + title: string; + description: string; + total_steps: number; + steps: WorkflowStepDef[]; + estimated_time_seconds?: number; + created_at: string; +} + +/** 工作流步骤执行状态 */ +export type WorkflowStepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'warning'; + +/** 工作流步骤执行结果 */ +export interface WorkflowStepResult { + step_number: number; + tool_code: string; + tool_name: string; + status: WorkflowStepStatus; + started_at?: string; + completed_at?: string; + duration_ms?: number; + result?: { + method?: string; + statistic?: number; + p_value?: number; + effect_size?: number; + interpretation?: string; + result_table?: { + headers: string[]; + rows: (string | number)[][]; + }; + plots?: PlotData[]; + [key: string]: unknown; + }; + error?: string; + logs: string[]; +} + +/** 综合结论报告 */ +export interface ConclusionReport { + workflow_id: string; + title: string; + executive_summary: string; + key_findings: string[]; + statistical_summary: { + total_tests: number; + significant_results: number; + methods_used: string[]; + }; + step_summaries: { + step_number: number; + tool_name: string; + summary: string; + p_value?: number; + is_significant?: boolean; + }[]; + recommendations: string[]; + limitations: string[]; + generated_at: string; +} + +/** SSE 消息类型 */ +export type SSEMessageType = 'connected' | 'step_start' | 'step_progress' | 'step_complete' | 'step_error' | 'workflow_complete' | 'workflow_error'; + +/** SSE 消息 */ +export interface SSEMessage { + type: SSEMessageType; + workflowId?: string; + step?: number; + total_steps?: number; + totalSteps?: number; + status?: WorkflowStepStatus; + message?: string; + // 后端使用驼峰命名 + toolCode?: string; + toolName?: string; + durationMs?: number; + duration_ms?: number; + result?: Record; + // 兼容嵌套格式 + data?: WorkflowStepResult & { + tool_code?: string; + tool_name?: string; + duration_ms?: number; + result?: Record; + }; + conclusion?: ConclusionReport; + error?: string; +} diff --git a/r-statistics-service/docker-compose.yml b/r-statistics-service/docker-compose.yml index 0cbd91d2..4aba838b 100644 --- a/r-statistics-service/docker-compose.yml +++ b/r-statistics-service/docker-compose.yml @@ -11,6 +11,7 @@ services: - DEV_MODE=true volumes: # 开发环境挂载:支持热重载 + - ./plumber.R:/app/plumber.R - ./tools:/app/tools - ./utils:/app/utils - ./tests:/app/tests diff --git a/r-statistics-service/plumber.R b/r-statistics-service/plumber.R index ff72af5b..ac65a04b 100644 --- a/r-statistics-service/plumber.R +++ b/r-statistics-service/plumber.R @@ -115,6 +115,57 @@ function() { ) } +#* JIT Guardrails Check +#* @post /api/v1/guardrails/jit +#* @serializer unboxedJSON +function(req) { + tryCatch({ + input <- jsonlite::fromJSON(req$postBody, simplifyVector = FALSE) + + # 必需参数 + tool_code <- input$tool_code + params <- input$params + + if (is.null(tool_code)) { + return(list( + status = "error", + error_code = "E400", + message = "Missing tool_code parameter" + )) + } + + # 加载数据 + df <- tryCatch( + load_input_data(input), + error = function(e) { + return(NULL) + } + ) + + if (is.null(df)) { + return(list( + status = "error", + error_code = "E100", + message = "Failed to load data for guardrail checks" + )) + } + + # 执行 JIT 护栏检查 + result <- run_jit_guardrails(df, tool_code, params) + + return(list( + status = "success", + checks = result$checks, + suggested_tool = result$suggested_tool, + can_proceed = result$can_proceed, + all_checks_passed = result$all_checks_passed + )) + + }, error = function(e) { + return(map_r_error(e$message)) + }) +} + #* 执行统计工具 #* @post /api/v1/skills/ #* @param tool_code:str 工具代码(如 ST_T_TEST_IND) diff --git a/r-statistics-service/tools/chi_square.R b/r-statistics-service/tools/chi_square.R new file mode 100644 index 00000000..39687032 --- /dev/null +++ b/r-statistics-service/tools/chi_square.R @@ -0,0 +1,254 @@ +#' @tool_code ST_CHI_SQUARE +#' @name 卡方检验 +#' @version 1.0.0 +#' @description 两个分类变量的独立性检验 +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + # ===== 初始化 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + + on.exit({}, add = TRUE) + + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + p <- input$params + var1 <- p$var1 + var2 <- p$var2 + + # ===== 参数校验 ===== + if (!(var1 %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = var1)) + } + if (!(var2 %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = var2)) + } + + # ===== 数据清洗 ===== + original_rows <- nrow(df) + df <- df[!is.na(df[[var1]]) & trimws(as.character(df[[var1]])) != "", ] + df <- df[!is.na(df[[var2]]) & trimws(as.character(df[[var2]])) != "", ] + + removed_rows <- original_rows - nrow(df) + if (removed_rows > 0) { + log_add(glue("数据清洗: 移除 {removed_rows} 行缺失值 (剩余 {nrow(df)} 行)")) + } + + # ===== 护栏检查 ===== + guardrail_results <- list() + warnings_list <- c() + + # 样本量检查 + sample_check <- check_sample_size(nrow(df), min_required = 10, action = ACTION_BLOCK) + guardrail_results <- c(guardrail_results, list(sample_check)) + log_add(glue("样本量检查: N = {nrow(df)}, {sample_check$reason}")) + + guardrail_status <- run_guardrail_chain(guardrail_results) + + if (guardrail_status$status == "blocked") { + return(list( + status = "blocked", + message = guardrail_status$reason, + trace_log = logs + )) + } + + # ===== 构建列联表 ===== + contingency_table <- table(df[[var1]], df[[var2]]) + log_add(glue("列联表维度: {nrow(contingency_table)} x {ncol(contingency_table)}")) + + # 检查列联表有效性 + if (nrow(contingency_table) < 2 || ncol(contingency_table) < 2) { + return(make_error(ERROR_CODES$E003_INSUFFICIENT_GROUPS, + col = paste(var1, "或", var2), + expected = 2, + actual = min(nrow(contingency_table), ncol(contingency_table)))) + } + + # ===== 期望频数检查(决定使用卡方还是 Fisher) ===== + expected <- chisq.test(contingency_table)$expected + low_expected_count <- sum(expected < 5) + total_cells <- length(expected) + low_expected_pct <- low_expected_count / total_cells + + use_fisher <- FALSE + is_2x2 <- nrow(contingency_table) == 2 && ncol(contingency_table) == 2 + + if (low_expected_pct > 0.2) { + warnings_list <- c(warnings_list, glue("期望频数 < 5 的格子占 {round(low_expected_pct * 100, 1)}%")) + if (is_2x2) { + use_fisher <- TRUE + log_add("2x2 表且期望频数不足,自动切换为 Fisher 精确检验") + } else { + log_add("期望频数不足,但非 2x2 表,继续使用卡方检验(结果需谨慎解读)") + } + } + + # ===== 核心计算 ===== + if (use_fisher) { + log_add("执行 Fisher 精确检验") + result <- fisher.test(contingency_table) + method_used <- "Fisher's Exact Test" + + output_results <- list( + method = method_used, + p_value = jsonlite::unbox(as.numeric(result$p.value)), + p_value_fmt = format_p_value(result$p.value), + odds_ratio = if (!is.null(result$estimate)) jsonlite::unbox(as.numeric(result$estimate)) else NULL, + conf_int = if (!is.null(result$conf.int)) as.numeric(result$conf.int) else NULL + ) + } else { + log_add("执行 Pearson 卡方检验") + result <- chisq.test(contingency_table, correct = is_2x2) # 2x2表使用Yates连续性校正 + method_used <- if (is_2x2) "Pearson's Chi-squared test with Yates' continuity correction" else "Pearson's Chi-squared test" + + # 计算 Cramér's V + n <- sum(contingency_table) + k <- min(nrow(contingency_table), ncol(contingency_table)) + cramers_v <- sqrt(result$statistic / (n * (k - 1))) + + # 效应量解释 + v_interpretation <- if (cramers_v < 0.1) "微小" else if (cramers_v < 0.3) "小" else if (cramers_v < 0.5) "中等" else "大" + + log_add(glue("χ² = {round(result$statistic, 3)}, df = {result$parameter}, p = {round(result$p.value, 4)}, Cramér's V = {round(cramers_v, 3)}")) + + output_results <- list( + method = method_used, + statistic = jsonlite::unbox(as.numeric(result$statistic)), + df = jsonlite::unbox(as.numeric(result$parameter)), + p_value = jsonlite::unbox(as.numeric(result$p.value)), + p_value_fmt = format_p_value(result$p.value), + effect_size = list( + cramers_v = jsonlite::unbox(round(as.numeric(cramers_v), 4)), + interpretation = v_interpretation + ) + ) + } + + # 添加列联表信息(精简版,不含原始数据) + # 将 table 转为纯数值矩阵以便 JSON 序列化 + observed_matrix <- matrix( + as.numeric(contingency_table), + nrow = nrow(contingency_table), + ncol = ncol(contingency_table), + dimnames = list(rownames(contingency_table), colnames(contingency_table)) + ) + + output_results$contingency_table <- list( + row_var = var1, + col_var = var2, + row_levels = as.character(rownames(contingency_table)), + col_levels = as.character(colnames(contingency_table)), + observed = observed_matrix, + row_totals = as.numeric(rowSums(contingency_table)), + col_totals = as.numeric(colSums(contingency_table)), + grand_total = jsonlite::unbox(sum(contingency_table)) + ) + + # ===== 生成图表 ===== + log_add("生成马赛克图") + plot_base64 <- tryCatch({ + generate_mosaic_plot(contingency_table, var1, var2) + }, error = function(e) { + log_add(paste("图表生成失败:", e$message)) + NULL + }) + + # ===== 生成可复现代码 ===== + original_filename <- if (!is.null(input$original_filename) && nchar(input$original_filename) > 0) { + input$original_filename + } else { + "data.csv" + } + + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: 卡方检验 +# 时间: {Sys.time()} +# ================================ + +library(ggplot2) + +# 数据准备 +df <- read.csv("{original_filename}") +var1 <- "{var1}" +var2 <- "{var2}" + +# 数据清洗 +df <- df[!is.na(df[[var1]]) & !is.na(df[[var2]]), ] + +# 构建列联表 +contingency_table <- table(df[[var1]], df[[var2]]) +print(contingency_table) + +# 卡方检验 +result <- chisq.test(contingency_table) +print(result) + +# 计算 Cramer V (效应量) +n <- sum(contingency_table) +k <- min(nrow(contingency_table), ncol(contingency_table)) +cramers_v <- sqrt(result$statistic / (n * (k - 1))) +cat("Cramer V =", round(cramers_v, 3), "\\n") + +# 可视化(马赛克图) +mosaicplot(contingency_table, main = "Mosaic Plot", color = TRUE) +') + + # ===== 返回结果 ===== + log_add("分析完成") + + return(list( + status = "success", + message = "分析完成", + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = output_results, + plots = if (!is.null(plot_base64)) list(plot_base64) else list(), + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# 辅助函数:生成马赛克图(使用 ggplot2 模拟) +generate_mosaic_plot <- function(contingency_table, var1, var2) { + # 转换为长格式数据 + df_plot <- as.data.frame(contingency_table) + names(df_plot) <- c("Var1", "Var2", "Freq") + + p <- ggplot(df_plot, aes(x = Var1, y = Freq, fill = Var2)) + + geom_bar(stat = "identity", position = "fill") + + scale_y_continuous(labels = scales::percent) + + theme_minimal() + + labs( + title = paste("Association between", var1, "and", var2), + x = var1, + y = "Proportion", + fill = var2 + ) + + scale_fill_brewer(palette = "Set2") + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 7, height = 5, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} diff --git a/r-statistics-service/tools/correlation.R b/r-statistics-service/tools/correlation.R new file mode 100644 index 00000000..654fc32c --- /dev/null +++ b/r-statistics-service/tools/correlation.R @@ -0,0 +1,242 @@ +#' @tool_code ST_CORRELATION +#' @name 相关分析 +#' @version 1.0.0 +#' @description Pearson/Spearman 相关系数计算 +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + # ===== 初始化 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + + on.exit({}, add = TRUE) + + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + p <- input$params + guardrails_cfg <- input$guardrails + + var_x <- p$var_x + var_y <- p$var_y + method <- tolower(p$method %||% "auto") # pearson, spearman, auto + + # ===== 参数校验 ===== + if (!(var_x %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = var_x)) + } + if (!(var_y %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = var_y)) + } + + # ===== 数据清洗 ===== + original_rows <- nrow(df) + df <- df[!is.na(df[[var_x]]) & !is.na(df[[var_y]]), ] + + removed_rows <- original_rows - nrow(df) + if (removed_rows > 0) { + log_add(glue("数据清洗: 移除 {removed_rows} 行缺失值 (剩余 {nrow(df)} 行)")) + } + + x_vals <- df[[var_x]] + y_vals <- df[[var_y]] + n <- length(x_vals) + + # ===== 护栏检查 ===== + guardrail_results <- list() + warnings_list <- c() + + # 样本量检查 + sample_check <- check_sample_size(n, min_required = 10, action = ACTION_WARN) + guardrail_results <- c(guardrail_results, list(sample_check)) + log_add(glue("样本量检查: N = {n}, {sample_check$reason}")) + + guardrail_status <- run_guardrail_chain(guardrail_results) + + if (guardrail_status$status == "blocked") { + return(list( + status = "blocked", + message = guardrail_status$reason, + trace_log = logs + )) + } + + if (length(guardrail_status$warnings) > 0) { + warnings_list <- c(warnings_list, guardrail_status$warnings) + } + + # ===== 方法选择 ===== + final_method <- method + + if (method == "auto") { + log_add("自动选择相关方法(检验正态性)") + + # 检验两个变量的正态性 + norm_x <- check_normality(x_vals, alpha = 0.05) + norm_y <- check_normality(y_vals, alpha = 0.05) + + log_add(glue("{var_x} 正态性: p = {round(norm_x$p_value, 4)}, {norm_x$reason}")) + log_add(glue("{var_y} 正态性: p = {round(norm_y$p_value, 4)}, {norm_y$reason}")) + + if (norm_x$passed && norm_y$passed) { + final_method <- "pearson" + log_add("两变量均满足正态性,使用 Pearson 相关") + } else { + final_method <- "spearman" + log_add("存在非正态变量,使用 Spearman 秩相关") + warnings_list <- c(warnings_list, "变量不满足正态性,自动切换为 Spearman 秩相关") + } + } + + # ===== 核心计算 ===== + log_add(glue("执行 {final_method} 相关分析")) + + result <- cor.test(x_vals, y_vals, method = final_method) + + r_value <- result$estimate + p_value <- result$p.value + + # 相关系数解释 + r_abs <- abs(r_value) + r_interpretation <- if (r_abs < 0.1) "可忽略" else if (r_abs < 0.3) "弱" else if (r_abs < 0.5) "中等" else if (r_abs < 0.7) "较强" else "强" + + log_add(glue("r = {round(r_value, 4)}, p = {round(p_value, 4)}, 相关强度: {r_interpretation}")) + + # ===== 生成图表 ===== + log_add("生成散点图") + plot_base64 <- tryCatch({ + generate_scatter_plot(df, var_x, var_y, r_value, p_value, final_method) + }, error = function(e) { + log_add(paste("图表生成失败:", e$message)) + NULL + }) + + # ===== 生成可复现代码 ===== + original_filename <- if (!is.null(input$original_filename) && nchar(input$original_filename) > 0) { + input$original_filename + } else { + "data.csv" + } + + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: 相关分析 +# 时间: {Sys.time()} +# ================================ + +library(ggplot2) + +# 数据准备 +df <- read.csv("{original_filename}") +var_x <- "{var_x}" +var_y <- "{var_y}" + +# 数据清洗 +df <- df[!is.na(df[[var_x]]) & !is.na(df[[var_y]]), ] + +# {final_method} 相关分析 +result <- cor.test(df[[var_x]], df[[var_y]], method = "{final_method}") +print(result) + +# 可视化 +ggplot(df, aes(x = .data[[var_x]], y = .data[[var_y]])) + + geom_point(alpha = 0.6) + + geom_smooth(method = "lm", se = TRUE, color = "#3b82f6") + + theme_minimal() + + labs(title = paste("Correlation:", var_x, "vs", var_y), + subtitle = paste("r =", round(result$estimate, 3), ", p =", round(result$p.value, 4))) +') + + # ===== 返回结果 ===== + log_add("分析完成") + + output_results <- list( + method = if (final_method == "pearson") "Pearson product-moment correlation" else "Spearman's rank correlation rho", + method_code = final_method, + statistic = jsonlite::unbox(round(as.numeric(r_value), 4)), + p_value = jsonlite::unbox(as.numeric(p_value)), + p_value_fmt = format_p_value(p_value), + interpretation = r_interpretation, + n = n, + variables = list(x = var_x, y = var_y), + descriptive = list( + x = list( + variable = var_x, + mean = round(mean(x_vals), 3), + sd = round(sd(x_vals), 3), + median = round(median(x_vals), 3) + ), + y = list( + variable = var_y, + mean = round(mean(y_vals), 3), + sd = round(sd(y_vals), 3), + median = round(median(y_vals), 3) + ) + ) + ) + + # Pearson 相关有置信区间 + if (final_method == "pearson" && !is.null(result$conf.int)) { + output_results$conf_int <- as.numeric(result$conf.int) + } + + return(list( + status = "success", + message = "分析完成", + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = output_results, + plots = if (!is.null(plot_base64)) list(plot_base64) else list(), + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# 辅助函数:生成散点图 +generate_scatter_plot <- function(df, var_x, var_y, r_value, p_value, method) { + # 限制点数防止图表过大 + if (nrow(df) > 1000) { + set.seed(42) + df <- df[sample(nrow(df), 1000), ] + } + + p <- ggplot(df, aes(x = .data[[var_x]], y = .data[[var_y]])) + + geom_point(alpha = 0.5, color = "#64748b", size = 2) + + geom_smooth(method = "lm", se = TRUE, color = "#3b82f6", fill = "#93c5fd") + + theme_minimal() + + labs( + title = paste("Correlation:", var_x, "vs", var_y), + subtitle = paste0( + ifelse(method == "pearson", "Pearson ", "Spearman "), + "r = ", round(r_value, 3), + ", p ", format_p_value(p_value) + ), + x = var_x, + y = var_y + ) + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 6, height = 5, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} + +# 辅助:空值合并运算符 +`%||%` <- function(a, b) if (is.null(a)) b else a diff --git a/r-statistics-service/tools/descriptive.R b/r-statistics-service/tools/descriptive.R new file mode 100644 index 00000000..af39c98a --- /dev/null +++ b/r-statistics-service/tools/descriptive.R @@ -0,0 +1,332 @@ +#' @tool_code ST_DESCRIPTIVE +#' @name 描述性统计 +#' @version 1.0.0 +#' @description 数据概况与基线特征表 +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + # ===== 初始化 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + + on.exit({}, add = TRUE) + + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + p <- input$params + variables <- p$variables # 变量列表(可选,空则分析全部) + group_var <- p$group_var # 分组变量(可选) + + # ===== 确定要分析的变量 ===== + if (is.null(variables) || length(variables) == 0) { + variables <- names(df) + log_add("未指定变量,分析全部列") + } + + # 排除分组变量本身 + 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) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, + col = paste(missing_vars, collapse = ", "))) + } + + # 校验分组变量 + groups <- NULL + if (!is.null(group_var) && group_var != "") { + if (!(group_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = group_var)) + } + groups <- unique(df[[group_var]][!is.na(df[[group_var]])]) + log_add(glue("分组变量: {group_var}, 分组: {paste(groups, collapse=', ')}")) + } + + # ===== 变量类型推断 ===== + 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) { + return("categorical") + } + return("numeric") + } else { + return("categorical") + } + }) + + log_add(glue("数值变量: {sum(var_types == 'numeric')}, 分类变量: {sum(var_types == 'categorical')}")) + + # ===== 计算描述性统计 ===== + warnings_list <- c() + results_list <- list() + + for (v in variables) { + var_type <- as.character(var_types[v]) + if (is.na(var_type) || length(var_type) == 0) { + var_type <- "categorical" # 默认为分类变量 + } + + if (is.null(groups)) { + # 无分组 + if (identical(var_type, "numeric")) { + stats <- calc_numeric_stats(df[[v]], v) + } else { + stats <- calc_categorical_stats(df[[v]], v) + } + stats$type <- var_type + results_list[[v]] <- stats + } else { + # 有分组 + group_stats <- list() + for (g in groups) { + subset_vals <- df[df[[group_var]] == g, v, drop = TRUE] + if (identical(var_type, "numeric")) { + group_stats[[as.character(g)]] <- calc_numeric_stats(subset_vals, v) + } else { + group_stats[[as.character(g)]] <- calc_categorical_stats(subset_vals, v) + } + } + results_list[[v]] <- list( + variable = v, + type = var_type, + by_group = group_stats + ) + } + } + + # ===== 总体概况 ===== + summary_stats <- list( + n_total = nrow(df), + n_variables = length(variables), + n_numeric = sum(var_types == "numeric"), + n_categorical = sum(var_types == "categorical") + ) + + if (!is.null(groups)) { + summary_stats$group_var <- group_var + summary_stats$groups <- lapply(groups, function(g) { + list(name = as.character(g), n = sum(df[[group_var]] == g, na.rm = TRUE)) + }) + } + + # ===== 生成图表 ===== + log_add("生成描述性统计图表") + plots <- list() + + # 只为前几个变量生成图表(避免过多) + vars_to_plot <- head(variables, 4) + + for (v in vars_to_plot) { + plot_base64 <- tryCatch({ + if (var_types[v] == "numeric") { + generate_histogram(df, v, group_var) + } else { + generate_bar_chart(df, v, group_var) + } + }, error = function(e) { + log_add(paste("图表生成失败:", v, e$message)) + NULL + }) + + if (!is.null(plot_base64)) { + plots <- c(plots, list(plot_base64)) + } + } + + # ===== 生成可复现代码 ===== + original_filename <- if (!is.null(input$original_filename) && nchar(input$original_filename) > 0) { + input$original_filename + } else { + "data.csv" + } + + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: 描述性统计 +# 时间: {Sys.time()} +# ================================ + +library(ggplot2) + +# 数据准备 +df <- read.csv("{original_filename}") + +# 数值变量描述性统计 +numeric_vars <- sapply(df, is.numeric) +if (any(numeric_vars)) {{ + summary(df[, numeric_vars, drop = FALSE]) +}} + +# 分类变量频数表 +categorical_vars <- !numeric_vars +if (any(categorical_vars)) {{ + for (v in names(df)[categorical_vars]) {{ + cat("\\n变量:", v, "\\n") + print(table(df[[v]], useNA = "ifany")) + }} +}} + +# 可视化示例 +# ggplot(df, aes(x = your_variable)) + geom_histogram() +') + + # ===== 返回结果 ===== + log_add("分析完成") + + return(list( + status = "success", + message = "分析完成", + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = list( + summary = summary_stats, + variables = results_list + ), + plots = plots, + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# ===== 辅助函数 ===== + +# 数值变量统计 +calc_numeric_stats <- function(vals, var_name) { + vals <- vals[!is.na(vals)] + n <- length(vals) + + if (n == 0) { + return(list( + variable = var_name, + n = 0, + missing = length(vals) - n, + stats = NULL + )) + } + + list( + variable = var_name, + n = n, + missing = 0, + mean = round(mean(vals), 3), + sd = round(sd(vals), 3), + median = round(median(vals), 3), + q1 = round(quantile(vals, 0.25), 3), + q3 = round(quantile(vals, 0.75), 3), + iqr = round(IQR(vals), 3), + min = round(min(vals), 3), + max = round(max(vals), 3), + skewness = round(calc_skewness(vals), 3), + formatted = paste0(round(mean(vals), 2), " ± ", round(sd(vals), 2)) + ) +} + +# 分类变量统计 +calc_categorical_stats <- function(vals, var_name) { + total <- length(vals) + valid <- sum(!is.na(vals)) + + freq_table <- table(vals, useNA = "no") + + levels_list <- lapply(names(freq_table), function(level) { + count <- as.numeric(freq_table[level]) + pct <- round(count / valid * 100, 1) + list( + level = level, + n = count, + pct = pct, + formatted = paste0(count, " (", pct, "%)") + ) + }) + + list( + variable = var_name, + n = valid, + missing = total - valid, + levels = levels_list + ) +} + +# 计算偏度 +calc_skewness <- function(x) { + n <- length(x) + if (n < 3) return(NA) + m <- mean(x) + s <- sd(x) + sum((x - m)^3) / (n * s^3) +} + +# 生成直方图 +generate_histogram <- function(df, var_name, group_var = NULL) { + if (!is.null(group_var) && group_var != "") { + p <- ggplot(df[!is.na(df[[var_name]]), ], aes(x = .data[[var_name]], fill = factor(.data[[group_var]]))) + + geom_histogram(alpha = 0.6, position = "identity", bins = 30) + + scale_fill_brewer(palette = "Set1", name = group_var) + + theme_minimal() + } else { + p <- ggplot(df[!is.na(df[[var_name]]), ], aes(x = .data[[var_name]])) + + geom_histogram(fill = "#3b82f6", alpha = 0.7, bins = 30) + + theme_minimal() + } + + p <- p + labs(title = paste("Distribution of", var_name), x = var_name, y = "Count") + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 6, height = 4, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} + +# 生成柱状图 +generate_bar_chart <- function(df, var_name, group_var = NULL) { + df_plot <- df[!is.na(df[[var_name]]), ] + + if (!is.null(group_var) && group_var != "") { + p <- ggplot(df_plot, aes(x = factor(.data[[var_name]]), fill = factor(.data[[group_var]]))) + + geom_bar(position = "dodge") + + scale_fill_brewer(palette = "Set1", name = group_var) + + theme_minimal() + } else { + p <- ggplot(df_plot, aes(x = factor(.data[[var_name]]))) + + geom_bar(fill = "#3b82f6", alpha = 0.7) + + theme_minimal() + } + + p <- p + labs(title = paste("Frequency of", var_name), x = var_name, y = "Count") + + theme(axis.text.x = element_text(angle = 45, hjust = 1)) + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 6, height = 4, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} diff --git a/r-statistics-service/tools/logistic_binary.R b/r-statistics-service/tools/logistic_binary.R new file mode 100644 index 00000000..31ac6a71 --- /dev/null +++ b/r-statistics-service/tools/logistic_binary.R @@ -0,0 +1,316 @@ +#' @tool_code ST_LOGISTIC_BINARY +#' @name 二元 Logistic 回归 +#' @version 1.0.0 +#' @description 二分类结局变量的多因素分析 +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + # ===== 初始化 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + + on.exit({}, add = TRUE) + + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + p <- input$params + outcome_var <- p$outcome_var + predictors <- p$predictors # 预测变量列表 + confounders <- p$confounders # 混杂因素(可选) + + # ===== 参数校验 ===== + if (!(outcome_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = outcome_var)) + } + + all_vars <- c(predictors, confounders) + all_vars <- all_vars[!is.null(all_vars) & all_vars != ""] + + for (v in all_vars) { + if (!(v %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = v)) + } + } + + if (length(predictors) == 0) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "至少需要一个预测变量")) + } + + # ===== 数据清洗 ===== + original_rows <- nrow(df) + + # 移除所有相关变量的缺失值 + vars_to_check <- c(outcome_var, all_vars) + for (v in vars_to_check) { + df <- df[!is.na(df[[v]]), ] + } + + removed_rows <- original_rows - nrow(df) + if (removed_rows > 0) { + log_add(glue("数据清洗: 移除 {removed_rows} 行缺失值 (剩余 {nrow(df)} 行)")) + } + + # ===== 结局变量检查 ===== + outcome_values <- unique(df[[outcome_var]]) + if (length(outcome_values) != 2) { + return(make_error(ERROR_CODES$E003_INSUFFICIENT_GROUPS, + col = outcome_var, expected = 2, actual = length(outcome_values))) + } + + # 确保结局变量是 0/1 或因子 + if (!is.factor(df[[outcome_var]])) { + df[[outcome_var]] <- as.factor(df[[outcome_var]]) + } + + # 事件数统计 + event_counts <- table(df[[outcome_var]]) + n_events <- min(event_counts) + n_predictors <- length(all_vars) + + log_add(glue("结局变量分布: {paste(names(event_counts), '=', event_counts, collapse=', ')}")) + log_add(glue("事件数: {n_events}, 预测变量数: {n_predictors}")) + + # ===== 护栏检查 ===== + guardrail_results <- list() + warnings_list <- c() + + # EPV 规则检查(Events Per Variable >= 10) + epv <- n_events / n_predictors + if (epv < 10) { + warnings_list <- c(warnings_list, glue("EPV = {round(epv, 1)} < 10,模型可能不稳定")) + log_add(glue("警告: EPV = {round(epv, 1)} < 10")) + } + + # 样本量检查 + sample_check <- check_sample_size(nrow(df), min_required = 20, action = ACTION_BLOCK) + guardrail_results <- c(guardrail_results, list(sample_check)) + + guardrail_status <- run_guardrail_chain(guardrail_results) + + if (guardrail_status$status == "blocked") { + return(list( + status = "blocked", + message = guardrail_status$reason, + trace_log = logs + )) + } + + # ===== 构建模型公式 ===== + formula_str <- paste(outcome_var, "~", paste(all_vars, collapse = " + ")) + formula_obj <- as.formula(formula_str) + log_add(glue("模型公式: {formula_str}")) + + # ===== 核心计算 ===== + log_add("拟合 Logistic 回归模型") + + model <- tryCatch({ + glm(formula_obj, data = df, family = binomial(link = "logit")) + }, error = function(e) { + log_add(paste("模型拟合失败:", e$message)) + return(NULL) + }, warning = function(w) { + warnings_list <<- c(warnings_list, w$message) + log_add(paste("模型警告:", w$message)) + invokeRestart("muffleWarning") + }) + + if (is.null(model)) { + return(map_r_error("模型拟合失败")) + } + + # 检查模型收敛 + if (!model$converged) { + warnings_list <- c(warnings_list, "模型未完全收敛") + log_add("警告: 模型未完全收敛") + } + + # ===== 提取模型结果 ===== + coef_summary <- summary(model)$coefficients + + # 计算 OR 和 95% CI + coef_table <- data.frame( + variable = rownames(coef_summary), + estimate = coef_summary[, "Estimate"], + std_error = coef_summary[, "Std. Error"], + z_value = coef_summary[, "z value"], + p_value = coef_summary[, "Pr(>|z|)"], + stringsAsFactors = FALSE + ) + + coef_table$OR <- exp(coef_table$estimate) + coef_table$ci_lower <- exp(coef_table$estimate - 1.96 * coef_table$std_error) + coef_table$ci_upper <- exp(coef_table$estimate + 1.96 * coef_table$std_error) + + # 转换为列表格式(精简,不含原始系数) + coefficients_list <- lapply(1:nrow(coef_table), function(i) { + row <- coef_table[i, ] + list( + variable = row$variable, + OR = round(row$OR, 3), + ci_lower = round(row$ci_lower, 3), + ci_upper = round(row$ci_upper, 3), + p_value = round(row$p_value, 4), + p_value_fmt = format_p_value(row$p_value), + significant = row$p_value < 0.05 + ) + }) + + # ===== 模型拟合度 ===== + null_deviance <- model$null.deviance + residual_deviance <- model$deviance + aic <- AIC(model) + + # Nagelkerke R²(伪 R²) + n <- nrow(df) + r2_nagelkerke <- (1 - exp((residual_deviance - null_deviance) / n)) / (1 - exp(-null_deviance / n)) + + log_add(glue("AIC = {round(aic, 2)}, Nagelkerke R² = {round(r2_nagelkerke, 3)}")) + + # ===== 共线性检测(VIF) ===== + vif_results <- NULL + if (length(all_vars) > 1) { + tryCatch({ + if (requireNamespace("car", quietly = TRUE)) { + vif_values <- car::vif(model) + if (is.matrix(vif_values)) { + vif_values <- vif_values[, "GVIF"] + } + vif_results <- lapply(names(vif_values), function(v) { + list(variable = v, vif = round(vif_values[v], 2)) + }) + + high_vif <- names(vif_values)[vif_values > 5] + if (length(high_vif) > 0) { + warnings_list <- c(warnings_list, paste("VIF > 5 的变量:", paste(high_vif, collapse = ", "))) + } + } + }, error = function(e) { + log_add(paste("VIF 计算失败:", e$message)) + }) + } + + # ===== 生成图表(森林图) ===== + log_add("生成森林图") + plot_base64 <- tryCatch({ + generate_forest_plot(coef_table) + }, error = function(e) { + log_add(paste("图表生成失败:", e$message)) + NULL + }) + + # ===== 生成可复现代码 ===== + original_filename <- if (!is.null(input$original_filename) && nchar(input$original_filename) > 0) { + input$original_filename + } else { + "data.csv" + } + + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: 二元 Logistic 回归 +# 时间: {Sys.time()} +# ================================ + +# 数据准备 +df <- read.csv("{original_filename}") + +# 模型拟合 +model <- glm({formula_str}, data = df, family = binomial(link = "logit")) +summary(model) + +# OR 和 95% CI +coef_summary <- summary(model)$coefficients +OR <- exp(coef_summary[, "Estimate"]) +CI_lower <- exp(coef_summary[, "Estimate"] - 1.96 * coef_summary[, "Std. Error"]) +CI_upper <- exp(coef_summary[, "Estimate"] + 1.96 * coef_summary[, "Std. Error"]) +results <- data.frame(OR = OR, CI_lower = CI_lower, CI_upper = CI_upper, + p_value = coef_summary[, "Pr(>|z|)"]) +print(round(results, 3)) + +# 模型拟合度 +cat("AIC:", AIC(model), "\\n") + +# VIF(需要 car 包) +# library(car) +# vif(model) +') + + # ===== 返回结果 ===== + log_add("分析完成") + + return(list( + status = "success", + message = "分析完成", + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = list( + method = "Binary Logistic Regression (glm, binomial)", + formula = formula_str, + n_observations = nrow(df), + n_predictors = n_predictors, + coefficients = coefficients_list, + model_fit = list( + aic = jsonlite::unbox(round(aic, 2)), + null_deviance = jsonlite::unbox(round(null_deviance, 2)), + residual_deviance = jsonlite::unbox(round(residual_deviance, 2)), + r2_nagelkerke = jsonlite::unbox(round(r2_nagelkerke, 4)) + ), + vif = vif_results, + epv = jsonlite::unbox(round(epv, 1)) + ), + plots = if (!is.null(plot_base64)) list(plot_base64) else list(), + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# 辅助函数:生成森林图 +generate_forest_plot <- function(coef_table) { + # 移除截距项 + plot_data <- coef_table[coef_table$variable != "(Intercept)", ] + + if (nrow(plot_data) == 0) { + return(NULL) + } + + plot_data$variable <- factor(plot_data$variable, levels = rev(plot_data$variable)) + + p <- ggplot(plot_data, aes(x = OR, y = variable)) + + geom_vline(xintercept = 1, linetype = "dashed", color = "gray50") + + geom_point(size = 3, color = "#3b82f6") + + geom_errorbarh(aes(xmin = ci_lower, xmax = ci_upper), height = 0.2, color = "#3b82f6") + + scale_x_log10() + + theme_minimal() + + labs( + title = "Forest Plot: Odds Ratios with 95% CI", + x = "Odds Ratio (log scale)", + y = "Variable" + ) + + theme( + panel.grid.minor = element_blank(), + axis.text.y = element_text(size = 10) + ) + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 8, height = max(4, nrow(plot_data) * 0.5 + 2), dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} diff --git a/r-statistics-service/tools/mann_whitney.R b/r-statistics-service/tools/mann_whitney.R new file mode 100644 index 00000000..91e6ee7e --- /dev/null +++ b/r-statistics-service/tools/mann_whitney.R @@ -0,0 +1,235 @@ +#' @tool_code ST_MANN_WHITNEY +#' @name Mann-Whitney U 检验 +#' @version 1.0.0 +#' @description 两组独立样本非参数比较(Wilcoxon秩和检验) +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + # ===== 初始化 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + + on.exit({}, add = TRUE) + + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + p <- input$params + group_var <- p$group_var + value_var <- p$value_var + + # ===== 参数校验 ===== + if (!(group_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = group_var)) + } + if (!(value_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = value_var)) + } + + # ===== 数据清洗 ===== + original_rows <- nrow(df) + df <- df[!is.na(df[[group_var]]) & trimws(as.character(df[[group_var]])) != "", ] + df <- df[!is.na(df[[value_var]]), ] + + removed_rows <- original_rows - nrow(df) + if (removed_rows > 0) { + log_add(glue("数据清洗: 移除 {removed_rows} 行缺失值 (剩余 {nrow(df)} 行)")) + } + + # 分组检查 + groups <- unique(df[[group_var]]) + if (length(groups) != 2) { + return(make_error(ERROR_CODES$E003_INSUFFICIENT_GROUPS, + col = group_var, expected = 2, actual = length(groups))) + } + + # 提取两组数据 + g1_vals <- df[df[[group_var]] == groups[1], value_var] + g2_vals <- df[df[[group_var]] == groups[2], value_var] + + # ===== 护栏检查 ===== + guardrail_results <- list() + warnings_list <- c() + + # 样本量检查(每组至少5个) + min_n <- min(length(g1_vals), length(g2_vals)) + sample_check <- check_sample_size(min_n, min_required = 5, action = ACTION_BLOCK) + guardrail_results <- c(guardrail_results, list(sample_check)) + log_add(glue("样本量检查: 每组最小 {min_n}, {sample_check$reason}")) + + guardrail_status <- run_guardrail_chain(guardrail_results) + + if (guardrail_status$status == "blocked") { + return(list( + status = "blocked", + message = guardrail_status$reason, + trace_log = logs + )) + } + + if (length(guardrail_status$warnings) > 0) { + warnings_list <- c(warnings_list, guardrail_status$warnings) + } + + # ===== 核心计算 ===== + log_add("执行 Mann-Whitney U 检验 (Wilcoxon rank-sum test)") + + result <- tryCatch({ + wilcox.test(g1_vals, g2_vals, exact = FALSE, correct = TRUE) + }, error = function(e) { + log_add(paste("Mann-Whitney U 检验失败:", e$message)) + return(NULL) + }) + + if (is.null(result)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "Mann-Whitney U 检验执行失败")) + } + + # 计算效应量 r = Z / sqrt(N) + n1 <- length(g1_vals) + n2 <- length(g2_vals) + N <- n1 + n2 + + # 从 U 统计量计算 Z 值 + U <- result$statistic + mu <- n1 * n2 / 2 + sigma <- sqrt(n1 * n2 * (n1 + n2 + 1) / 12) + z_value <- (U - mu) / sigma + effect_r <- abs(z_value) / sqrt(N) + + # 效应量解释 + effect_interpretation <- if (effect_r < 0.1) "微小" else if (effect_r < 0.3) "小" else if (effect_r < 0.5) "中等" else "大" + + log_add(glue("U = {round(U, 2)}, Z = {round(z_value, 3)}, p = {round(result$p.value, 4)}, r = {round(effect_r, 3)}")) + + # ===== 生成图表 ===== + log_add("生成箱线图") + plot_base64 <- tryCatch({ + generate_boxplot(df, group_var, value_var) + }, error = function(e) { + log_add(paste("图表生成失败:", e$message)) + NULL + }) + + # ===== 生成可复现代码 ===== + original_filename <- if (!is.null(input$original_filename) && nchar(input$original_filename) > 0) { + input$original_filename + } else { + "data.csv" + } + + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: Mann-Whitney U 检验 +# 时间: {Sys.time()} +# ================================ + +library(ggplot2) + +# 数据准备 +df <- read.csv("{original_filename}") +group_var <- "{group_var}" +value_var <- "{value_var}" + +# 数据清洗 +df <- df[!is.na(df[[group_var]]) & !is.na(df[[value_var]]), ] + +# Mann-Whitney U 检验 +g1_vals <- df[df[[group_var]] == "{groups[1]}", value_var] +g2_vals <- df[df[[group_var]] == "{groups[2]}", value_var] +result <- wilcox.test(g1_vals, g2_vals, exact = FALSE, correct = TRUE) +print(result) + +# 计算效应量 r +n1 <- length(g1_vals) +n2 <- length(g2_vals) +U <- result$statistic +mu <- n1 * n2 / 2 +sigma <- sqrt(n1 * n2 * (n1 + n2 + 1) / 12) +z_value <- (U - mu) / sigma +effect_r <- abs(z_value) / sqrt(n1 + n2) +cat("Effect size r =", round(effect_r, 3), "\\n") + +# 可视化 +ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]])) + + geom_boxplot(fill = "#8b5cf6", alpha = 0.6) + + theme_minimal() + + labs(title = paste("Distribution of", value_var, "by", group_var)) +') + + # ===== 返回结果 ===== + log_add("分析完成") + + return(list( + status = "success", + message = "分析完成", + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = list( + method = "Wilcoxon rank sum test with continuity correction", + statistic_U = jsonlite::unbox(as.numeric(U)), + z_value = jsonlite::unbox(round(z_value, 4)), + p_value = jsonlite::unbox(as.numeric(result$p.value)), + p_value_fmt = format_p_value(result$p.value), + effect_size = list( + r = jsonlite::unbox(round(effect_r, 4)), + interpretation = effect_interpretation + ), + group_stats = list( + list( + group = as.character(groups[1]), + n = n1, + median = median(g1_vals), + iqr = IQR(g1_vals), + min = min(g1_vals), + max = max(g1_vals) + ), + list( + group = as.character(groups[2]), + n = n2, + median = median(g2_vals), + iqr = IQR(g2_vals), + min = min(g2_vals), + max = max(g2_vals) + ) + ) + ), + plots = if (!is.null(plot_base64)) list(plot_base64) else list(), + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# 辅助函数:生成箱线图 +generate_boxplot <- function(df, group_var, value_var) { + p <- ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]])) + + geom_boxplot(fill = "#8b5cf6", alpha = 0.6) + + geom_jitter(width = 0.2, alpha = 0.3, size = 1) + + theme_minimal() + + labs( + title = paste("Distribution of", value_var, "by", group_var), + subtitle = "Mann-Whitney U Test" + ) + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 6, height = 4, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} diff --git a/r-statistics-service/tools/t_test_paired.R b/r-statistics-service/tools/t_test_paired.R new file mode 100644 index 00000000..8edc65e3 --- /dev/null +++ b/r-statistics-service/tools/t_test_paired.R @@ -0,0 +1,274 @@ +#' @tool_code ST_T_TEST_PAIRED +#' @name 配对 T 检验 +#' @version 1.0.0 +#' @description 配对设计的均值差异检验(前后对比) +#' @author SSA-Pro Team + +library(glue) +library(ggplot2) +library(base64enc) + +run_analysis <- function(input) { + # ===== 初始化 ===== + logs <- c() + log_add <- function(msg) { logs <<- c(logs, paste0("[", Sys.time(), "] ", msg)) } + + on.exit({}, add = TRUE) + + # ===== 数据加载 ===== + log_add("开始加载输入数据") + df <- tryCatch( + load_input_data(input), + error = function(e) { + log_add(paste("数据加载失败:", e$message)) + return(NULL) + } + ) + + if (is.null(df)) { + return(make_error(ERROR_CODES$E100_INTERNAL_ERROR, details = "数据加载失败")) + } + log_add(glue("数据加载成功: {nrow(df)} 行, {ncol(df)} 列")) + + p <- input$params + guardrails_cfg <- input$guardrails + + before_var <- p$before_var + after_var <- p$after_var + + # ===== 参数校验 ===== + if (!(before_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = before_var)) + } + if (!(after_var %in% names(df))) { + return(make_error(ERROR_CODES$E001_COLUMN_NOT_FOUND, col = after_var)) + } + + # ===== 数据清洗 ===== + original_rows <- nrow(df) + df <- df[!is.na(df[[before_var]]) & !is.na(df[[after_var]]), ] + + removed_rows <- original_rows - nrow(df) + if (removed_rows > 0) { + log_add(glue("数据清洗: 移除 {removed_rows} 行缺失值 (剩余 {nrow(df)} 行)")) + } + + before_vals <- df[[before_var]] + after_vals <- df[[after_var]] + diff_vals <- after_vals - before_vals + + n <- length(diff_vals) + + # ===== 护栏检查 ===== + guardrail_results <- list() + warnings_list <- c() + method_used <- "t.test" + use_wilcoxon <- FALSE + + # 样本量检查 + sample_check <- check_sample_size(n, min_required = 10, action = ACTION_WARN) + guardrail_results <- c(guardrail_results, list(sample_check)) + log_add(glue("样本量检查: N = {n}, {sample_check$reason}")) + + # 差值正态性检验 + if (isTRUE(guardrails_cfg$check_normality) && n >= 3) { + log_add("执行差值正态性检验") + norm_check <- check_normality(diff_vals, alpha = 0.05, + action = ACTION_SWITCH, + action_target = "Wilcoxon signed-rank test") + guardrail_results <- c(guardrail_results, list(norm_check)) + log_add(glue("差值正态性: p = {round(norm_check$p_value, 4)}, {norm_check$reason}")) + } + + guardrail_status <- run_guardrail_chain(guardrail_results) + + if (guardrail_status$status == "blocked") { + return(list( + status = "blocked", + message = guardrail_status$reason, + trace_log = logs + )) + } + + if (guardrail_status$status == "switch") { + use_wilcoxon <- TRUE + log_add(glue("触发方法切换: {guardrail_status$reason}")) + warnings_list <- c(warnings_list, "差值不满足正态性,自动切换为 Wilcoxon 符号秩检验") + } + + if (length(guardrail_status$warnings) > 0) { + warnings_list <- c(warnings_list, guardrail_status$warnings) + } + + # ===== 核心计算 ===== + if (use_wilcoxon) { + log_add("执行 Wilcoxon 符号秩检验") + result <- wilcox.test(before_vals, after_vals, paired = TRUE, exact = FALSE) + method_used <- "Wilcoxon signed rank test" + + # Wilcoxon 效应量 r + z_value <- qnorm(result$p.value / 2) * sign(median(diff_vals)) + effect_r <- abs(z_value) / sqrt(n) + effect_interpretation <- if (abs(effect_r) < 0.1) "微小" else if (abs(effect_r) < 0.3) "小" else if (abs(effect_r) < 0.5) "中等" else "大" + + output_results <- list( + method = method_used, + statistic = jsonlite::unbox(as.numeric(result$statistic)), + p_value = jsonlite::unbox(as.numeric(result$p.value)), + p_value_fmt = format_p_value(result$p.value), + effect_size = list( + r = jsonlite::unbox(round(effect_r, 4)), + interpretation = effect_interpretation + ) + ) + } else { + log_add("执行配对 T 检验") + result <- t.test(before_vals, after_vals, paired = TRUE) + method_used <- "Paired t-test" + + # Cohen's d for paired samples + mean_diff <- mean(diff_vals) + sd_diff <- sd(diff_vals) + cohens_d <- mean_diff / sd_diff + effect_interpretation <- if (abs(cohens_d) < 0.2) "微小" else if (abs(cohens_d) < 0.5) "小" else if (abs(cohens_d) < 0.8) "中等" else "大" + + log_add(glue("t = {round(result$statistic, 3)}, df = {round(result$parameter, 1)}, p = {round(result$p.value, 4)}, Cohen's d = {round(cohens_d, 3)}")) + + output_results <- list( + method = method_used, + statistic = jsonlite::unbox(as.numeric(result$statistic)), + df = jsonlite::unbox(as.numeric(result$parameter)), + p_value = jsonlite::unbox(as.numeric(result$p.value)), + p_value_fmt = format_p_value(result$p.value), + conf_int = as.numeric(result$conf.int), + effect_size = list( + cohens_d = jsonlite::unbox(round(cohens_d, 4)), + interpretation = effect_interpretation + ) + ) + } + + # 添加描述性统计 + output_results$descriptive <- list( + before = list( + variable = before_var, + n = n, + mean = round(mean(before_vals), 3), + sd = round(sd(before_vals), 3), + median = round(median(before_vals), 3) + ), + after = list( + variable = after_var, + n = n, + mean = round(mean(after_vals), 3), + sd = round(sd(after_vals), 3), + median = round(median(after_vals), 3) + ), + difference = list( + mean = round(mean(diff_vals), 3), + sd = round(sd(diff_vals), 3), + median = round(median(diff_vals), 3) + ) + ) + + # ===== 生成图表 ===== + log_add("生成配对比较图") + plot_base64 <- tryCatch({ + generate_paired_plot(df, before_var, after_var, diff_vals) + }, error = function(e) { + log_add(paste("图表生成失败:", e$message)) + NULL + }) + + # ===== 生成可复现代码 ===== + original_filename <- if (!is.null(input$original_filename) && nchar(input$original_filename) > 0) { + input$original_filename + } else { + "data.csv" + } + + reproducible_code <- glue(' +# SSA-Pro 自动生成代码 +# 工具: 配对 T 检验 +# 时间: {Sys.time()} +# ================================ + +library(ggplot2) + +# 数据准备 +df <- read.csv("{original_filename}") +before_var <- "{before_var}" +after_var <- "{after_var}" + +# 数据清洗 +df <- df[!is.na(df[[before_var]]) & !is.na(df[[after_var]]), ] + +# 配对 T 检验 +before_vals <- df[[before_var]] +after_vals <- df[[after_var]] +result <- t.test(before_vals, after_vals, paired = TRUE) +print(result) + +# Cohen d (效应量) +diff_vals <- after_vals - before_vals +cohens_d <- mean(diff_vals) / sd(diff_vals) +cat("Cohen d =", round(cohens_d, 3), "\\n") + +# 可视化 +df_long <- data.frame( + id = rep(1:nrow(df), 2), + time = rep(c("Before", "After"), each = nrow(df)), + value = c(before_vals, after_vals) +) +ggplot(df_long, aes(x = time, y = value, group = id)) + + geom_line(alpha = 0.3) + + geom_point() + + theme_minimal() + + labs(title = "Paired Comparison") +') + + # ===== 返回结果 ===== + log_add("分析完成") + + return(list( + status = "success", + message = "分析完成", + warnings = if (length(warnings_list) > 0) warnings_list else NULL, + results = output_results, + plots = if (!is.null(plot_base64)) list(plot_base64) else list(), + trace_log = logs, + reproducible_code = as.character(reproducible_code) + )) +} + +# 辅助函数:生成配对比较图 +generate_paired_plot <- function(df, before_var, after_var, diff_vals) { + n <- nrow(df) + + # 创建长格式数据 + df_long <- data.frame( + id = rep(1:n, 2), + time = factor(rep(c("Before", "After"), each = n), levels = c("Before", "After")), + value = c(df[[before_var]], df[[after_var]]) + ) + + p <- ggplot(df_long, aes(x = time, y = value)) + + geom_line(aes(group = id), alpha = 0.3, color = "gray60") + + geom_point(aes(group = id), alpha = 0.5, size = 2) + + stat_summary(fun = mean, geom = "point", size = 4, color = "#ef4444", shape = 18) + + stat_summary(fun = mean, geom = "line", aes(group = 1), color = "#ef4444", size = 1.2) + + theme_minimal() + + labs( + title = paste("Paired Comparison:", before_var, "vs", after_var), + subtitle = paste("n =", n, ", Mean change =", round(mean(diff_vals), 2)), + x = "Time Point", + y = "Value" + ) + + tmp_file <- tempfile(fileext = ".png") + ggsave(tmp_file, p, width = 6, height = 5, dpi = 100) + base64_str <- base64encode(tmp_file) + unlink(tmp_file) + + return(paste0("data:image/png;base64,", base64_str)) +} diff --git a/r-statistics-service/utils/data_loader.R b/r-statistics-service/utils/data_loader.R index 679b3bcc..ad16d54d 100644 --- a/r-statistics-service/utils/data_loader.R +++ b/r-statistics-service/utils/data_loader.R @@ -29,23 +29,59 @@ load_input_data <- function(input) { # 调试:打印原始数据结构 message(glue("[DataLoader] 原始数据类型: {class(raw_data)}")) - message(glue("[DataLoader] 原始数据字段: {paste(names(raw_data), collapse=', ')}")) + message(glue("[DataLoader] 原始数据长度: {length(raw_data)}")) # 安全转换:处理不同的 JSON 解析结果 if (is.data.frame(raw_data)) { + # 已经是 data.frame df <- raw_data - } else if (is.list(raw_data)) { - # JSON 对象 {"col1": [...], "col2": [...]} -> data.frame - # JSON 数组可能被解析为 list 而非 vector,需要先 unlist - df <- data.frame( - lapply(raw_data, function(x) { - if (is.list(x)) unlist(x) else x - }), - stringsAsFactors = FALSE - ) + message("[DataLoader] 数据已是 data.frame") + + } else if (is.list(raw_data) && length(raw_data) > 0) { + # 检查是行格式还是列格式 + first_elem <- raw_data[[1]] + + if (is.list(first_elem) && !is.null(names(first_elem))) { + # 行格式: [{"col1": val1, "col2": val2}, {...}, ...] + # 每个元素是一行数据 + message("[DataLoader] 检测到行格式数据 (JSON array of objects)") + + # 使用 jsonlite 的 bind_rows 功能 + df <- tryCatch({ + # 方法1:使用 do.call + rbind.data.frame + df_list <- lapply(raw_data, function(row) { + # 将每一行转为 data.frame + as.data.frame(lapply(row, function(val) { + if (is.null(val)) NA else val + }), stringsAsFactors = FALSE) + }) + do.call(rbind, df_list) + }, error = function(e) { + # 方法2:如果上面失败,尝试 jsonlite 转换 + message(glue("[DataLoader] rbind 失败,尝试 jsonlite 转换: {e$message}")) + jsonlite::fromJSON(jsonlite::toJSON(raw_data), flatten = TRUE) + }) + + } else if (!is.null(names(raw_data))) { + # 列格式: {"col1": [...], "col2": [...]} + message("[DataLoader] 检测到列格式数据 (JSON object with arrays)") + df <- data.frame( + lapply(raw_data, function(x) { + if (is.list(x)) unlist(x) else x + }), + stringsAsFactors = FALSE + ) + + } else { + # 未知格式 + message(glue("[DataLoader] 未知数据格式,first_elem class: {class(first_elem)}")) + stop(make_error(ERROR_CODES$E100_INTERNAL_ERROR, + details = "无法识别的数据格式")) + } + } else { stop(make_error(ERROR_CODES$E100_INTERNAL_ERROR, - details = paste("无法解析的数据类型:", class(raw_data)))) + details = paste("无法解析的数据类型:", class(raw_data), "或数据为空"))) } message(glue("[DataLoader] 转换后: {nrow(df)} 行, {ncol(df)} 列, 列名: {paste(names(df), collapse=', ')}")) diff --git a/r-statistics-service/utils/guardrails.R b/r-statistics-service/utils/guardrails.R index b19478d9..24b973f4 100644 --- a/r-statistics-service/utils/guardrails.R +++ b/r-statistics-service/utils/guardrails.R @@ -114,3 +114,129 @@ run_guardrail_chain <- function(guardrail_results) { warnings = warnings )) } + +# ========== JIT 护栏接口(Phase 2A) ========== +# 用于 WorkflowExecutor 在执行核心工具前调用 + +#' JIT 护栏检查:执行核心统计前检验假设 +#' @param df 数据框 +#' @param tool_code 目标工具代码 +#' @param params 工具参数(group_var, value_var 等) +#' @return list(checks, suggested_tool, can_proceed) +run_jit_guardrails <- function(df, tool_code, params) { + checks <- list() + suggested_tool <- tool_code + can_proceed <- TRUE + + # 根据工具类型执行不同的检验 + if (tool_code %in% c("ST_T_TEST_IND", "ST_MANN_WHITNEY")) { + # 独立样本比较:需要正态性 + 方差齐性检验 + group_var <- params$group_var + value_var <- params$value_var + + if (!is.null(group_var) && !is.null(value_var)) { + groups <- unique(df[[group_var]]) + + # 正态性检验(分组) + for (g in groups) { + vals <- df[df[[group_var]] == g, value_var] + if (length(vals) >= 3) { + norm_result <- check_normality(vals, alpha = 0.05) + checks <- c(checks, list(list( + check_name = glue("正态性检验 (组: {g})"), + passed = norm_result$passed, + p_value = norm_result$p_value, + recommendation = if (norm_result$passed) "满足正态性" else "建议使用非参数方法" + ))) + + if (!norm_result$passed && tool_code == "ST_T_TEST_IND") { + suggested_tool <- "ST_MANN_WHITNEY" + } + } + } + + # 方差齐性检验 + if (length(groups) == 2) { + tryCatch({ + homo_result <- check_homogeneity(df, group_var, value_var, alpha = 0.05) + checks <- c(checks, list(list( + check_name = "方差齐性检验 (Levene)", + passed = homo_result$passed, + p_value = homo_result$p_value, + recommendation = if (homo_result$passed) "方差齐性满足" else "建议使用 Welch 校正" + ))) + }, error = function(e) { + message("方差齐性检验失败: ", e$message) + }) + } + } + + } else if (tool_code == "ST_T_TEST_PAIRED") { + # 配对检验:需要差值正态性检验 + before_var <- params$before_var + after_var <- params$after_var + + if (!is.null(before_var) && !is.null(after_var)) { + diff_vals <- df[[after_var]] - df[[before_var]] + diff_vals <- diff_vals[!is.na(diff_vals)] + + if (length(diff_vals) >= 3) { + norm_result <- check_normality(diff_vals, alpha = 0.05) + checks <- c(checks, list(list( + check_name = "差值正态性检验", + passed = norm_result$passed, + p_value = norm_result$p_value, + recommendation = if (norm_result$passed) "差值满足正态性" else "建议使用 Wilcoxon 符号秩检验" + ))) + + if (!norm_result$passed) { + suggested_tool <- "Wilcoxon signed-rank test" + } + } + } + + } else if (tool_code == "ST_CORRELATION") { + # 相关分析:需要双变量正态性检验 + var_x <- params$var_x + var_y <- params$var_y + + if (!is.null(var_x) && !is.null(var_y)) { + x_vals <- df[[var_x]][!is.na(df[[var_x]])] + y_vals <- df[[var_y]][!is.na(df[[var_y]])] + + if (length(x_vals) >= 3) { + norm_x <- check_normality(x_vals, alpha = 0.05) + checks <- c(checks, list(list( + check_name = glue("正态性检验 ({var_x})"), + passed = norm_x$passed, + p_value = norm_x$p_value, + recommendation = if (norm_x$passed) "满足正态性" else "建议使用 Spearman 秩相关" + ))) + } + + if (length(y_vals) >= 3) { + norm_y <- check_normality(y_vals, alpha = 0.05) + checks <- c(checks, list(list( + check_name = glue("正态性检验 ({var_y})"), + passed = norm_y$passed, + p_value = norm_y$p_value, + recommendation = if (norm_y$passed) "满足正态性" else "建议使用 Spearman 秩相关" + ))) + + if (!norm_x$passed || !norm_y$passed) { + suggested_tool <- "ST_CORRELATION (Spearman)" + } + } + } + } + + # 汇总 + all_passed <- all(sapply(checks, function(c) c$passed)) + + return(list( + checks = checks, + suggested_tool = suggested_tool, + can_proceed = TRUE, # 即使检验不通过也允许继续,由用户/LLM 决定 + all_checks_passed = all_passed + )) +}