feat(ssa): Complete Phase I-IV intelligent dialogue and tool system development

Phase I - Session Blackboard + READ Layer:
- SessionBlackboardService with Postgres-Only cache
- DataProfileService for data overview generation
- PicoInferenceService for LLM-driven PICO extraction
- Frontend DataContextCard and VariableDictionaryPanel
- E2E tests: 31/31 passed

Phase II - Conversation Layer LLM + Intent Router:
- ConversationService with SSE streaming
- IntentRouterService (rule-first + LLM fallback, 6 intents)
- SystemPromptService with 6-segment dynamic assembly
- TokenTruncationService for context management
- ChatHandlerService as unified chat entry
- Frontend SSAChatPane and useSSAChat hook
- E2E tests: 38/38 passed

Phase III - Method Consultation + AskUser Standardization:
- ToolRegistryService with Repository Pattern
- MethodConsultService with DecisionTable + LLM enhancement
- AskUserService with global interrupt handling
- Frontend AskUserCard component
- E2E tests: 13/13 passed

Phase IV - Dialogue-Driven Analysis + QPER Integration:
- ToolOrchestratorService (plan/execute/report)
- analysis_plan SSE event for WorkflowPlan transmission
- Dual-channel confirmation (ask_user card + workspace button)
- PICO as optional hint for LLM parsing
- E2E tests: 25/25 passed

R Statistics Service:
- 5 new R tools: anova_one, baseline_table, fisher, linear_reg, wilcoxon
- Enhanced guardrails and block helpers
- Comprehensive test suite (run_all_tools_test.js)

Documentation:
- Updated system status document (v5.9)
- Updated SSA module status and development plan (v1.8)

Total E2E: 107/107 passed (Phase I: 31, Phase II: 38, Phase III: 13, Phase IV: 25)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-22 18:53:39 +08:00
parent bf10dec4c8
commit 3446909ff7
68 changed files with 11583 additions and 412 deletions

View File

@@ -0,0 +1,424 @@
#' @tool_code ST_ANOVA_ONE
#' @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
guardrails_cfg <- input$guardrails
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)} 行)"))
}
# 确保数值型
if (!is.numeric(df[[value_var]])) {
df[[value_var]] <- as.numeric(as.character(df[[value_var]]))
df <- df[!is.na(df[[value_var]]), ]
}
# 分组信息
df[[group_var]] <- as.factor(df[[group_var]])
groups <- levels(df[[group_var]])
n_groups <- length(groups)
if (n_groups < 3) {
return(make_error(ERROR_CODES$E003_INSUFFICIENT_GROUPS,
col = group_var, expected = "3+", actual = n_groups))
}
log_add(glue("分组变量 '{group_var}' 有 {n_groups} 个水平: {paste(groups, collapse=', ')}"))
# ===== 护栏检查 =====
guardrail_results <- list()
warnings_list <- c()
use_kruskal <- FALSE
# 每组样本量检查
group_sizes <- table(df[[group_var]])
min_group_n <- min(group_sizes)
sample_check <- check_sample_size(min_group_n, min_required = 3, action = ACTION_BLOCK)
guardrail_results <- c(guardrail_results, list(sample_check))
log_add(glue("最小组样本量: {min_group_n}, {sample_check$reason}"))
# 正态性检验(每组)
if (isTRUE(guardrails_cfg$check_normality)) {
log_add("执行正态性检验")
normality_failed <- FALSE
for (g in groups) {
vals <- df[df[[group_var]] == g, value_var]
if (length(vals) >= 3 && length(vals) <= 5000) {
norm_check <- check_normality(vals, alpha = 0.05, action = ACTION_SWITCH, action_target = "Kruskal-Wallis")
guardrail_results <- c(guardrail_results, list(norm_check))
log_add(glue("组[{g}] 正态性: p = {round(norm_check$p_value, 4)}, {norm_check$reason}"))
if (!norm_check$passed) normality_failed <- TRUE
}
}
if (normality_failed) use_kruskal <- TRUE
}
# 方差齐性检验 (Levene)
if (!use_kruskal) {
tryCatch({
homo_check <- check_homogeneity(df, group_var, value_var, alpha = 0.05, action = ACTION_WARN)
guardrail_results <- c(guardrail_results, list(homo_check))
log_add(glue("方差齐性 (Levene): p = {round(homo_check$p_value, 4)}, {homo_check$reason}"))
if (!homo_check$passed) {
warnings_list <- c(warnings_list, "方差不齐性,使用 Welch 校正的 ANOVA")
}
}, error = function(e) {
log_add(paste("方差齐性检验失败:", e$message))
})
}
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_kruskal <- TRUE
warnings_list <- c(warnings_list, guardrail_status$reason)
log_add(glue("正态性不满足,切换为 Kruskal-Wallis 检验"))
}
if (length(guardrail_status$warnings) > 0) {
warnings_list <- c(warnings_list, guardrail_status$warnings)
}
# ===== 各组描述统计 =====
group_stats <- lapply(groups, function(g) {
vals <- df[df[[group_var]] == g, value_var]
list(
group = g,
n = length(vals),
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)
)
})
# ===== 核心计算 =====
if (use_kruskal) {
log_add("执行 Kruskal-Wallis 检验")
formula_obj <- as.formula(paste(value_var, "~", group_var))
result <- kruskal.test(formula_obj, data = df)
method_used <- "Kruskal-Wallis rank sum test"
stat_name <- "H"
# 效应量: η² (eta-squared approximation for Kruskal-Wallis)
eta_sq <- (result$statistic - n_groups + 1) / (nrow(df) - n_groups)
eta_sq <- max(0, as.numeric(eta_sq))
output_results <- list(
method = method_used,
statistic = jsonlite::unbox(as.numeric(result$statistic)),
statistic_name = stat_name,
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(
eta_squared = jsonlite::unbox(round(eta_sq, 4)),
interpretation = interpret_eta_sq(eta_sq)
),
group_stats = group_stats
)
# 事后多重比较: Dunn test (pairwise Wilcoxon)
posthoc_result <- tryCatch({
pw <- pairwise.wilcox.test(df[[value_var]], df[[group_var]], p.adjust.method = "bonferroni")
pw
}, error = function(e) {
log_add(paste("Dunn 事后检验失败:", e$message))
NULL
})
} else {
log_add("执行单因素 ANOVA")
formula_obj <- as.formula(paste(value_var, "~", group_var))
# 检查方差齐性决定使用经典 ANOVA 还是 Welch ANOVA
use_welch <- any(grepl("方差不齐性", warnings_list))
if (use_welch) {
result <- oneway.test(formula_obj, data = df, var.equal = FALSE)
method_used <- "One-way ANOVA (Welch correction)"
} else {
aov_result <- aov(formula_obj, data = df)
result_summary <- summary(aov_result)
result <- list(
statistic = result_summary[[1]]$`F value`[1],
parameter = c(result_summary[[1]]$Df[1], result_summary[[1]]$Df[2]),
p.value = result_summary[[1]]$`Pr(>F)`[1]
)
method_used <- "One-way ANOVA"
}
stat_name <- "F"
# 效应量: η² (eta-squared)
ss_between <- sum(tapply(df[[value_var]], df[[group_var]], function(x) length(x) * (mean(x) - mean(df[[value_var]]))^2))
ss_total <- sum((df[[value_var]] - mean(df[[value_var]]))^2)
eta_sq <- ss_between / ss_total
f_val <- if (is.list(result)) result$statistic else as.numeric(result$statistic)
df_val <- if (is.list(result) && !is.null(result$parameter)) {
if (length(result$parameter) == 2) result$parameter else as.numeric(result$parameter)
} else {
as.numeric(result$parameter)
}
p_val <- if (is.list(result)) result$p.value else as.numeric(result$p.value)
output_results <- list(
method = method_used,
statistic = jsonlite::unbox(as.numeric(f_val)),
statistic_name = stat_name,
df = if (length(df_val) == 2) as.numeric(df_val) else jsonlite::unbox(as.numeric(df_val)),
p_value = jsonlite::unbox(as.numeric(p_val)),
p_value_fmt = format_p_value(p_val),
effect_size = list(
eta_squared = jsonlite::unbox(round(eta_sq, 4)),
interpretation = interpret_eta_sq(eta_sq)
),
group_stats = group_stats
)
# 事后多重比较: Tukey HSD (if classic ANOVA) or pairwise t-test
posthoc_result <- tryCatch({
if (use_welch) {
pairwise.t.test(df[[value_var]], df[[group_var]], p.adjust.method = "bonferroni", pool.sd = FALSE)
} else {
TukeyHSD(aov(formula_obj, data = df))
}
}, error = function(e) {
log_add(paste("事后多重比较失败:", e$message))
NULL
})
}
log_add(glue("{stat_name} = {round(as.numeric(output_results$statistic), 3)}, P = {round(as.numeric(output_results$p_value), 4)}"))
# 整理事后比较结果
posthoc_pairs <- NULL
if (!is.null(posthoc_result)) {
if (inherits(posthoc_result, "TukeyHSD")) {
tukey_df <- as.data.frame(posthoc_result[[1]])
posthoc_pairs <- lapply(seq_len(nrow(tukey_df)), function(i) {
list(
comparison = rownames(tukey_df)[i],
diff = round(tukey_df$diff[i], 3),
ci_lower = round(tukey_df$lwr[i], 3),
ci_upper = round(tukey_df$upr[i], 3),
p_adj = round(tukey_df$`p adj`[i], 4),
p_adj_fmt = format_p_value(tukey_df$`p adj`[i]),
significant = tukey_df$`p adj`[i] < 0.05
)
})
} else if (inherits(posthoc_result, "pairwise.htest")) {
p_matrix <- posthoc_result$p.value
for (i in seq_len(nrow(p_matrix))) {
for (j in seq_len(ncol(p_matrix))) {
if (!is.na(p_matrix[i, j])) {
if (is.null(posthoc_pairs)) posthoc_pairs <- list()
posthoc_pairs[[length(posthoc_pairs) + 1]] <- list(
comparison = paste(rownames(p_matrix)[i], "vs", colnames(p_matrix)[j]),
p_adj = round(p_matrix[i, j], 4),
p_adj_fmt = format_p_value(p_matrix[i, j]),
significant = p_matrix[i, j] < 0.05
)
}
}
}
}
}
output_results$posthoc <- posthoc_pairs
# ===== 生成图表 =====
log_add("生成箱线图")
plot_base64 <- tryCatch({
generate_anova_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 自动生成代码
# 工具: 单因素方差分析
# 时间: {Sys.time()}
# ================================
library(ggplot2)
# 数据准备
df <- read.csv("{original_filename}")
group_var <- "{group_var}"
value_var <- "{value_var}"
# 单因素 ANOVA
result <- aov(as.formula(paste(value_var, "~", group_var)), data = df)
summary(result)
# 事后多重比较 (Tukey HSD)
TukeyHSD(result)
# 可视化
ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]], fill = .data[[group_var]])) +
geom_boxplot(alpha = 0.7) +
theme_minimal() +
labs(title = paste("Distribution of", value_var, "by", group_var))
')
# ===== 构建 report_blocks =====
blocks <- list()
# Block 1: 各组描述统计
desc_headers <- c("组别", "N", "均值", "标准差", "中位数")
desc_rows <- lapply(group_stats, function(gs) {
c(gs$group, as.character(gs$n), as.character(gs$mean), as.character(gs$sd), as.character(gs$median))
})
blocks[[length(blocks) + 1]] <- make_table_block(desc_headers, desc_rows, title = "各组描述统计")
# Block 2: 检验结果
kv_items <- list(
"方法" = method_used,
"统计量" = paste0(stat_name, " = ", round(as.numeric(output_results$statistic), 3)),
"P 值" = output_results$p_value_fmt,
"η²" = as.character(output_results$effect_size$eta_squared),
"效应量解释" = output_results$effect_size$interpretation
)
blocks[[length(blocks) + 1]] <- make_kv_block(kv_items, title = "检验结果")
# Block 3: 事后多重比较
if (!is.null(posthoc_pairs) && length(posthoc_pairs) > 0) {
ph_headers <- c("比较", "P 值 (校正)", "显著性")
ph_rows <- lapply(posthoc_pairs, function(pair) {
sig <- if (pair$significant) "*" else ""
c(pair$comparison, pair$p_adj_fmt, sig)
})
blocks[[length(blocks) + 1]] <- make_table_block(ph_headers, ph_rows,
title = "事后多重比较",
footnote = if (use_kruskal) "Bonferroni 校正的 Wilcoxon 检验" else "Tukey HSD / Bonferroni 校正")
}
# Block 4: 箱线图
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(plot_base64,
title = paste(value_var, "by", group_var),
alt = paste("箱线图:", value_var, "按", group_var, "分组"))
}
# Block 5: 结论摘要
p_val_num <- as.numeric(output_results$p_value)
sig_text <- if (p_val_num < 0.05) "各组间存在统计学显著差异" else "各组间差异无统计学意义"
conclusion <- glue("{method_used}: {stat_name} = {round(as.numeric(output_results$statistic), 3)}, P {output_results$p_value_fmt}。{sig_text}(η² = {output_results$effect_size$eta_squared}{output_results$effect_size$interpretation}效应)。")
if (!is.null(posthoc_pairs) && p_val_num < 0.05) {
sig_pairs <- Filter(function(x) x$significant, posthoc_pairs)
if (length(sig_pairs) > 0) {
pair_names <- sapply(sig_pairs, function(x) x$comparison)
conclusion <- paste0(conclusion, glue("\n\n事后比较显示以下组间差异显著{paste(pair_names, collapse = '、')}。"))
}
}
blocks[[length(blocks) + 1]] <- make_markdown_block(conclusion, title = "结论摘要")
# ===== 返回结果 =====
log_add("分析完成")
return(list(
status = "success",
message = "分析完成",
warnings = if (length(warnings_list) > 0) warnings_list else NULL,
results = output_results,
report_blocks = blocks,
plots = if (!is.null(plot_base64)) list(plot_base64) else list(),
trace_log = logs,
reproducible_code = as.character(reproducible_code)
))
}
# η² 效应量解释
interpret_eta_sq <- function(eta_sq) {
if (eta_sq < 0.01) return("微小")
if (eta_sq < 0.06) return("小")
if (eta_sq < 0.14) return("中等")
return("大")
}
# NULL 合并运算符
`%||%` <- function(x, y) if (is.null(x)) y else x
# 辅助函数ANOVA 箱线图
generate_anova_boxplot <- function(df, group_var, value_var) {
p <- ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]], fill = .data[[group_var]])) +
geom_boxplot(alpha = 0.7, outlier.shape = 21) +
stat_summary(fun = mean, geom = "point", shape = 18, size = 3, color = "red") +
theme_minimal() +
labs(
title = paste("Distribution of", value_var, "by", group_var),
x = group_var,
y = value_var
) +
scale_fill_brewer(palette = "Set2") +
theme(legend.position = "none")
tmp_file <- tempfile(fileext = ".png")
ggsave(tmp_file, p, width = max(7, length(unique(df[[group_var]])) * 1.5), height = 5, dpi = 100)
base64_str <- base64encode(tmp_file)
unlink(tmp_file)
return(paste0("data:image/png;base64,", base64_str))
}