feat(ssa): Complete QPER architecture - Query, Planner, Execute, Reflection layers

Implement the full QPER intelligent analysis pipeline:

- Phase E+: Block-based standardization for all 7 R tools, DynamicReport renderer, Word export enhancement

- Phase Q: LLM intent parsing with dynamic Zod validation against real column names, ClarificationCard component, DataProfile is_id_like tagging

- Phase P: ConfigLoader with Zod schema validation and hot-reload API, DecisionTableService (4-dimension matching), FlowTemplateService with EPV protection, PlannedTrace audit output

- Phase R: ReflectionService with statistical slot injection, sensitivity analysis conflict rules, ConclusionReport with section reveal animation, conclusion caching API, graceful R error classification

End-to-end test: 40/40 passed across two complete analysis scenarios.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 18:15:53 +08:00
parent 428a22adf2
commit 371e1c069c
73 changed files with 9242 additions and 706 deletions

View File

@@ -16,6 +16,7 @@ source("utils/error_codes.R")
source("utils/data_loader.R")
source("utils/guardrails.R")
source("utils/result_formatter.R")
source("utils/block_helpers.R")
# 工具目录
tools_dir <- "tools"

View File

@@ -213,6 +213,60 @@ cat("Cramer V =", round(cramers_v, 3), "\\n")
mosaicplot(contingency_table, main = "Mosaic Plot", color = TRUE)
')
# ===== 构建 report_blocks =====
# Block 1: 列联表
table_headers <- c(var1, as.character(colnames(contingency_table)))
table_rows <- lapply(seq_len(nrow(contingency_table)), function(i) {
c(as.character(rownames(contingency_table)[i]), as.character(contingency_table[i, ]))
})
blocks <- list(
make_table_block(table_headers, table_rows, title = "列联表")
)
# Block 2: 检验结果键值对
if (use_fisher) {
kv_items <- list(
"方法" = method_used,
"P 值" = output_results$p_value_fmt
)
if (!is.null(output_results$odds_ratio)) {
kv_items[["比值比"]] <- as.character(round(as.numeric(output_results$odds_ratio), 4))
}
if (!is.null(output_results$conf_int)) {
kv_items[["95% 置信区间"]] <- sprintf("[%.4f, %.4f]", output_results$conf_int[1], output_results$conf_int[2])
}
} else {
kv_items <- list(
"方法" = method_used,
"χ² 统计量" = as.character(round(as.numeric(output_results$statistic), 4)),
"自由度" = as.character(output_results$df),
"P 值" = output_results$p_value_fmt,
"Cramér's V" = as.character(output_results$effect_size$cramers_v),
"效应量解释" = output_results$effect_size$interpretation
)
}
blocks[[length(blocks) + 1]] <- make_kv_block(kv_items, title = "检验结果")
# Block 3: 马赛克图(若有)
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(plot_base64, title = "马赛克图")
}
# Block 4: 结论摘要
p_val <- as.numeric(output_results$p_value)
conclusion <- if (p_val < 0.05) {
glue("α=0.05 水平下,{var1} 与 {var2} 之间存在显著关联P {output_results$p_value_fmt})。")
} else {
glue("α=0.05 水平下,未发现 {var1} 与 {var2} 之间的显著关联P {output_results$p_value_fmt})。")
}
if (!use_fisher) {
conclusion <- paste0(conclusion, " 效应量为", output_results$effect_size$interpretation,
"Cramér's V = ", output_results$effect_size$cramers_v, ")。")
} else if (!is.null(output_results$odds_ratio)) {
conclusion <- paste0(conclusion, " 比值比 = ", round(as.numeric(output_results$odds_ratio), 4), "。")
}
blocks[[length(blocks) + 1]] <- make_markdown_block(conclusion, title = "结论摘要")
# ===== 返回结果 =====
log_add("分析完成")
@@ -221,6 +275,7 @@ mosaicplot(contingency_table, main = "Mosaic Plot", color = TRUE)
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)

View File

@@ -195,12 +195,50 @@ ggplot(df, aes(x = .data[[var_x]], y = .data[[var_y]])) +
if (final_method == "pearson" && !is.null(result$conf.int)) {
output_results$conf_int <- as.numeric(result$conf.int)
}
# ===== 构建 report_blocks =====
blocks <- list()
# Block 1: 分析概况
blocks[[length(blocks) + 1]] <- make_kv_block(list(
"变量 X" = var_x,
"变量 Y" = var_y,
"样本量" = as.character(n),
"分析方法" = final_method
), title = "分析概况")
# Block 2: 相关分析结果表
ci_str <- if (final_method == "pearson" && !is.null(result$conf.int)) {
sprintf("[%.3f, %.3f]", result$conf.int[1], result$conf.int[2])
} else {
"-"
}
corr_headers <- c("r 值", "P 值", "95% CI", "相关强度")
corr_rows <- list(c(
as.character(round(r_value, 4)),
format_p_value(p_value),
ci_str,
r_interpretation
))
blocks[[length(blocks) + 1]] <- make_table_block(corr_headers, corr_rows, title = "相关分析结果")
# Block 3: 散点图
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(plot_base64, title = "散点图", alt = paste(var_x, "vs", var_y))
}
# Block 4: 结论摘要
conclusion_text <- glue(
"**{var_x}** 与 **{var_y}** 的 {final_method} 相关系数为 r = {round(r_value, 3)} (P {format_p_value(p_value)}),相关强度为 **{r_interpretation}**。"
)
blocks[[length(blocks) + 1]] <- make_markdown_block(conclusion_text, title = "结论摘要")
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)

View File

@@ -199,7 +199,83 @@ if (any(categorical_vars)) {{
# ===== 返回结果 =====
log_add("分析完成")
# ===== 构建 report_blocks =====
blocks <- list()
# Block 1: 数据概况
kv_items <- list(
"总样本量" = as.character(summary_stats$n_total),
"变量数" = as.character(summary_stats$n_variables),
"数值变量数" = as.character(summary_stats$n_numeric),
"分类变量数" = as.character(summary_stats$n_categorical)
)
if (!is.null(groups)) {
kv_items$group_var <- group_var
kv_items$groups <- paste(sapply(summary_stats$groups, function(g) paste0(g$name, "(n=", g$n, ")")), collapse = ", ")
}
blocks[[length(blocks) + 1]] <- make_kv_block(kv_items, title = "数据概况")
# Block 2: 数值变量汇总表
numeric_vars <- names(results_list)[sapply(results_list, function(x) {
if (is.list(x) && !is.null(x$type)) x$type == "numeric" else FALSE
})]
if (length(numeric_vars) > 0) {
if (is.null(groups)) {
num_headers <- c("变量名", "n", "mean", "sd", "median", "Q1", "Q3", "min", "max")
num_rows <- lapply(numeric_vars, function(v) {
s <- results_list[[v]]
c(v, as.character(s$n), as.character(s$mean), as.character(s$sd),
as.character(s$median), as.character(s$q1), as.character(s$q3),
as.character(s$min), as.character(s$max))
})
} else {
num_headers <- c("变量名", as.character(groups))
num_rows <- lapply(numeric_vars, function(v) {
s <- results_list[[v]]
row <- c(v)
for (g in groups) {
gs <- s$by_group[[as.character(g)]]
row <- c(row, if (!is.null(gs$formatted)) gs$formatted else "-")
}
row
})
}
blocks[[length(blocks) + 1]] <- make_table_block(num_headers, num_rows, title = "数值变量汇总表")
}
# Block 3: 分类变量汇总表
cat_vars <- names(results_list)[sapply(results_list, function(x) {
if (is.list(x) && !is.null(x$type)) x$type == "categorical" else FALSE
})]
if (length(cat_vars) > 0) {
cat_headers <- c("变量名", "水平", "n", "百分比")
cat_rows <- list()
for (v in cat_vars) {
s <- results_list[[v]]
if (is.null(groups)) {
for (lev in s$levels) {
cat_rows[[length(cat_rows) + 1]] <- c(v, lev$level, as.character(lev$n), paste0(lev$pct, "%"))
}
} else {
for (g in groups) {
gs <- s$by_group[[as.character(g)]]
for (lev in gs$levels) {
cat_rows[[length(cat_rows) + 1]] <- c(paste0(v, " (", g, ")"), lev$level, as.character(lev$n), paste0(lev$pct, "%"))
}
}
}
}
if (length(cat_rows) > 0) {
blocks[[length(blocks) + 1]] <- make_table_block(cat_headers, cat_rows, title = "分类变量汇总表")
}
}
# Block 4+: 各图表
for (i in seq_along(plots)) {
blocks[[length(blocks) + 1]] <- make_image_block(plots[[i]], title = paste0("图表 ", i), alt = paste0("描述性统计图 ", i))
}
return(list(
status = "success",
message = "分析完成",
@@ -208,6 +284,7 @@ if (any(categorical_vars)) {{
summary = summary_stats,
variables = results_list
),
report_blocks = blocks,
plots = plots,
trace_log = logs,
reproducible_code = as.character(reproducible_code)

View File

@@ -254,7 +254,59 @@ cat("AIC:", AIC(model), "\\n")
# ===== 返回结果 =====
log_add("分析完成")
# ===== 构建 report_blocks =====
blocks <- list()
# Block 1: 模型概况
blocks[[length(blocks) + 1]] <- make_kv_block(list(
"模型公式" = formula_str,
"观测数" = as.character(nrow(df)),
"预测变量数" = as.character(n_predictors),
"AIC" = as.character(round(aic, 2)),
"Nagelkerke R²" = as.character(round(r2_nagelkerke, 4)),
"EPV" = as.character(round(epv, 1))
), title = "模型概况")
# Block 2: 回归系数表
coef_headers <- c("变量", "OR", "95% CI", "P 值", "显著性")
coef_rows <- lapply(coefficients_list, function(row) {
ci_str <- sprintf("[%.3f, %.3f]", row$ci_lower, row$ci_upper)
sig <- if (row$significant) "*" else ""
c(row$variable, as.character(row$OR), ci_str, row$p_value_fmt, sig)
})
blocks[[length(blocks) + 1]] <- make_table_block(coef_headers, coef_rows, title = "回归系数表", footnote = "* P < 0.05")
# Block 3: VIF 表(如存在)
if (!is.null(vif_results) && length(vif_results) > 0) {
vif_headers <- c("变量", "VIF")
vif_rows <- lapply(vif_results, function(row) c(row$variable, as.character(row$vif)))
blocks[[length(blocks) + 1]] <- make_table_block(vif_headers, vif_rows, title = "方差膨胀因子 (VIF)")
}
# Block 4: 森林图(如存在)
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(plot_base64, title = "森林图", alt = "Odds Ratios Forest Plot")
}
# Block 5: 结论摘要
sig_vars <- sapply(coefficients_list, function(r) if (r$variable != "(Intercept)" && r$significant) r$variable else NULL)
sig_vars <- unlist(sig_vars[!sapply(sig_vars, is.null)])
conclusion_lines <- c(
glue("模型拟合指标AIC = {round(aic, 2)}Nagelkerke R² = {round(r2_nagelkerke, 4)}。"),
""
)
if (length(sig_vars) > 0) {
conclusion_lines <- c(conclusion_lines,
glue("α = 0.05 水平下,以下变量具有统计学意义:**{paste(sig_vars, collapse = '**, **')}**。"),
""
)
} else {
conclusion_lines <- c(conclusion_lines, "α = 0.05 水平下,无预测变量达到统计学意义。", "")
}
conclusion_lines <- c(conclusion_lines, glue("EPV = {round(epv, 1)}(建议 ≥ 10。"))
blocks[[length(blocks) + 1]] <- make_markdown_block(paste(conclusion_lines, collapse = "\n"), title = "结论摘要")
return(list(
status = "success",
message = "分析完成",
@@ -274,6 +326,7 @@ cat("AIC:", AIC(model), "\\n")
vif = vif_results,
epv = jsonlite::unbox(round(epv, 1))
),
report_blocks = blocks,
plots = if (!is.null(plot_base64)) list(plot_base64) else list(),
trace_log = logs,
reproducible_code = as.character(reproducible_code)

View File

@@ -173,6 +173,62 @@ ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]])) +
labs(title = paste("Distribution of", value_var, "by", group_var))
')
# ===== 构建 report_blocks =====
log_add("构建 report_blocks")
blocks <- list()
# Block 1: 样本概况(两组 n, median, IQR
g1_label <- as.character(groups[1])
g2_label <- as.character(groups[2])
blocks[[length(blocks) + 1]] <- make_kv_block(
title = "样本概况",
items = list(
list(key = paste0(g1_label, " (n, Median, IQR)"),
value = paste0("n=", n1, ", ", round(median(g1_vals), 3), ", ", round(IQR(g1_vals), 3))),
list(key = paste0(g2_label, " (n, Median, IQR)"),
value = paste0("n=", n2, ", ", round(median(g2_vals), 3), ", ", round(IQR(g2_vals), 3)))
)
)
# Block 2: 检验结果U 统计量, Z 值, P 值, 效应量 r
blocks[[length(blocks) + 1]] <- make_table_block(
title = "Mann-Whitney U 检验结果",
headers = c("U 统计量", "Z 值", "P 值", "效应量 r", "效应量解释"),
rows = list(
list(
round(as.numeric(U), 4),
round(z_value, 4),
format_p_value(result$p.value),
round(effect_r, 4),
effect_interpretation
)
),
footnote = "Wilcoxon rank sum test with continuity correction"
)
# Block 3: 箱线图(如果 plot_base64 不为 NULL
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(
base64_data = plot_base64,
title = paste0(value_var, " by ", group_var),
alt = paste("箱线图:", value_var, "按", group_var, "分组")
)
}
# Block 4: 结论摘要
sig <- if (result$p.value < 0.05) "存在统计学显著差异" else "差异无统计学意义"
blocks[[length(blocks) + 1]] <- make_markdown_block(
title = "结果摘要",
content = paste0(
"两组 **", value_var, "** 的比较Mann-Whitney U 检验):",
"U = ", round(as.numeric(U), 2),
"Z = ", round(z_value, 3),
"P ", format_p_value(result$p.value),
",效应量 r = ", round(effect_r, 3), "", effect_interpretation, ")。",
"两组间", sig, "。"
)
)
# ===== 返回结果 =====
log_add("分析完成")
@@ -209,6 +265,7 @@ ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]])) +
)
)
),
report_blocks = blocks,
plots = if (!is.null(plot_base64)) list(plot_base64) else list(),
trace_log = logs,
reproducible_code = as.character(reproducible_code)

View File

@@ -180,6 +180,66 @@ ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]])) +
labs(title = paste("Distribution of", value_var, "by", group_var))
')
# ===== 构建 report_blocks =====
log_add("构建 report_blocks")
blocks <- list()
# Block 1: 描述统计键值对
g1_label <- as.character(groups[1])
g2_label <- as.character(groups[2])
blocks[[length(blocks) + 1]] <- make_kv_block(
title = "样本概况",
items = list(
list(key = paste0(group_var, " = ", g1_label, " (n)"), value = as.character(length(g1_vals))),
list(key = paste0(group_var, " = ", g2_label, " (n)"), value = as.character(length(g2_vals))),
list(key = paste0(g1_label, " Mean ± SD"),
value = paste0(round(mean(g1_vals), 3), " \u00b1 ", round(sd(g1_vals), 3))),
list(key = paste0(g2_label, " Mean ± SD"),
value = paste0(round(mean(g2_vals), 3), " \u00b1 ", round(sd(g2_vals), 3)))
)
)
# Block 2: 检验结果表格
blocks[[length(blocks) + 1]] <- make_table_block(
title = "独立样本 T 检验结果",
headers = c("统计量", "自由度", "P 值", "95% CI 下限", "95% CI 上限", "均值差"),
rows = list(
list(
round(as.numeric(result$statistic), 4),
round(as.numeric(result$parameter), 2),
format_p_value(result$p.value),
round(result$conf.int[1], 4),
round(result$conf.int[2], 4),
round(diff(result$estimate), 4)
)
),
footnote = result$method
)
# Block 3: 箱线图
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(
base64_data = plot_base64,
title = paste0(value_var, " by ", group_var),
alt = paste("箱线图:", value_var, "按", group_var, "分组")
)
}
# Block 4: 结论摘要
sig <- if (result$p.value < 0.05) "存在统计学显著差异" else "差异无统计学意义"
blocks[[length(blocks) + 1]] <- make_markdown_block(
title = "结果摘要",
content = paste0(
"两组 **", value_var, "** 的比较(", result$method, "",
"t = ", round(as.numeric(result$statistic), 3),
"df = ", round(as.numeric(result$parameter), 1),
"P ", format_p_value(result$p.value),
"。两组间", sig, "。"
)
)
# ===== 返回结果 =====
log_add("分析完成")
@@ -196,10 +256,11 @@ ggplot(df, aes(x = .data[[group_var]], y = .data[[value_var]])) +
conf_int = as.numeric(result$conf.int),
estimate = as.numeric(result$estimate),
group_stats = list(
list(group = as.character(groups[1]), n = length(g1_vals), mean = mean(g1_vals), sd = sd(g1_vals)),
list(group = as.character(groups[2]), n = length(g2_vals), mean = mean(g2_vals), sd = sd(g2_vals))
list(group = g1_label, n = length(g1_vals), mean = mean(g1_vals), sd = sd(g1_vals)),
list(group = g2_label, n = length(g2_vals), mean = mean(g2_vals), sd = sd(g2_vals))
)
),
report_blocks = blocks,
plots = if (!is.null(plot_base64)) list(plot_base64) else list(),
trace_log = logs,
reproducible_code = as.character(reproducible_code)

View File

@@ -226,7 +226,98 @@ ggplot(df_long, aes(x = time, y = value, group = id)) +
theme_minimal() +
labs(title = "Paired Comparison")
')
# ===== 构建 report_blocks =====
d <- output_results$descriptive
blocks <- list()
# Block 1: 样本概况
blocks[[length(blocks) + 1]] <- make_kv_block(
title = "样本概况",
items = list(
list(key = paste0(before_var, " (n)"), value = as.character(d$before$n)),
list(key = paste0(before_var, " Mean"), value = as.character(d$before$mean)),
list(key = paste0(before_var, " SD"), value = as.character(d$before$sd)),
list(key = paste0(before_var, " Median"), value = as.character(d$before$median)),
list(key = paste0(after_var, " (n)"), value = as.character(d$after$n)),
list(key = paste0(after_var, " Mean"), value = as.character(d$after$mean)),
list(key = paste0(after_var, " SD"), value = as.character(d$after$sd)),
list(key = paste0(after_var, " Median"), value = as.character(d$after$median)),
list(key = "差值 Mean", value = as.character(d$difference$mean)),
list(key = "差值 SD", value = as.character(d$difference$sd))
)
)
# Block 2: 检验结果表格(根据 use_wilcoxon 区分)
if (use_wilcoxon) {
blocks[[length(blocks) + 1]] <- make_table_block(
title = "Wilcoxon 符号秩检验结果",
headers = c("统计量 V", "P 值", "效应量 r", "效应量解释"),
rows = list(list(
round(as.numeric(output_results$statistic), 4),
format_p_value(output_results$p_value),
round(output_results$effect_size$r, 4),
output_results$effect_size$interpretation
)),
footnote = method_used
)
} else {
ci_str <- if (length(output_results$conf_int) >= 2) {
sprintf("[%.4f, %.4f]", output_results$conf_int[1], output_results$conf_int[2])
} else {
"—"
}
blocks[[length(blocks) + 1]] <- make_table_block(
title = "配对 T 检验结果",
headers = c("t", "df", "P 值", "95% CI", "Cohen's d", "效应量解释"),
rows = list(list(
round(as.numeric(output_results$statistic), 4),
round(as.numeric(output_results$df), 2),
format_p_value(output_results$p_value),
ci_str,
round(output_results$effect_size$cohens_d, 4),
output_results$effect_size$interpretation
)),
footnote = method_used
)
}
# Block 3: 配对比较图
if (!is.null(plot_base64)) {
blocks[[length(blocks) + 1]] <- make_image_block(
base64_data = plot_base64,
title = paste0("配对比较: ", before_var, " vs ", after_var),
alt = paste("配对比较图:", before_var, "与", after_var)
)
}
# Block 4: 结论摘要
sig <- if (output_results$p_value < 0.05) "存在统计学显著差异" else "差异无统计学意义"
if (use_wilcoxon) {
concl <- paste0(
"配对样本 **", before_var, "** 与 **", after_var, "** 的比较(", method_used, "",
"V = ", round(as.numeric(output_results$statistic), 3),
"P ", format_p_value(output_results$p_value),
",效应量 r = ", round(output_results$effect_size$r, 3),
"", output_results$effect_size$interpretation, ")。",
sig, "。"
)
} else {
concl <- paste0(
"配对样本 **", before_var, "** 与 **", after_var, "** 的比较(", method_used, "",
"t = ", round(as.numeric(output_results$statistic), 3),
"df = ", round(as.numeric(output_results$df), 1),
"P ", format_p_value(output_results$p_value),
"Cohen's d = ", round(output_results$effect_size$cohens_d, 3),
"", output_results$effect_size$interpretation, ")。",
sig, "。"
)
}
blocks[[length(blocks) + 1]] <- make_markdown_block(
title = "结果摘要",
content = concl
)
# ===== 返回结果 =====
log_add("分析完成")
@@ -235,6 +326,7 @@ ggplot(df_long, aes(x = time, y = value, group = id)) +
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)

View File

@@ -0,0 +1,85 @@
# utils/block_helpers.R
# Block-based 输出协议 — 构造函数
#
# 所有 R 工具通过这些函数构建 report_blocks[]
# 前端 DynamicReport.tsx 根据 block.type 统一渲染。
# 支持 4 种 Block 类型markdown / table / image / key_value
#' 构造 Markdown 文本块
#' @param content Markdown 格式文本(支持标题、列表、加粗等)
#' @param title 可选标题(前端渲染为区块标题)
#' @return block list
make_markdown_block <- function(content, title = NULL) {
block <- list(type = "markdown", content = content)
if (!is.null(title)) block$title <- title
block
}
#' 构造表格块
#' @param headers 列名字符向量,如 c("组别", "均值", "标准差")
#' @param rows 行数据列表,每行为字符向量,如 list(c("A", "5.2", "1.3"), ...)
#' @param title 可选表格标题
#' @param footnote 可选脚注(如方法说明)
#' @return block list
make_table_block <- function(headers, rows, title = NULL, footnote = NULL) {
block <- list(
type = "table",
headers = as.list(headers),
rows = lapply(rows, as.list)
)
if (!is.null(title)) block$title <- title
if (!is.null(footnote)) block$footnote <- footnote
block
}
#' 从 data.frame 构造表格块(便捷方法)
#' @param df data.frame
#' @param title 可选表格标题
#' @param footnote 可选脚注
#' @param digits 数值列保留小数位数,默认 3
#' @return block list
make_table_block_from_df <- function(df, title = NULL, footnote = NULL, digits = 3) {
headers <- colnames(df)
rows <- lapply(seq_len(nrow(df)), function(i) {
lapply(df[i, , drop = FALSE], function(val) {
if (is.numeric(val)) {
format(round(val, digits), nsmall = digits)
} else {
as.character(val)
}
})
})
make_table_block(headers, rows, title = title, footnote = footnote)
}
#' 构造图片块
#' @param base64_data 完整的 data URI如 "data:image/png;base64,..."
#' @param title 可选图片标题
#' @param alt 可选 alt 文本(无障碍 + Word 导出用)
#' @return block list
make_image_block <- function(base64_data, title = NULL, alt = NULL) {
block <- list(type = "image", data = base64_data)
if (!is.null(title)) block$title <- title
if (!is.null(alt)) block$alt <- alt
block
}
#' 构造键值对块
#' @param items 命名列表或 list(list(key=..., value=...), ...)
#' @param title 可选标题
#' @return block list
make_kv_block <- function(items, title = NULL) {
if (!is.null(names(items)) && length(names(items)) > 0 && names(items)[1] != "") {
kv_list <- lapply(names(items), function(k) {
list(key = k, value = as.character(items[[k]]))
})
} else {
kv_list <- items
}
block <- list(type = "key_value", items = kv_list)
if (!is.null(title)) block$title <- title
block
}