← 返回

问卷考试系统设计(六):报告生成——模板引擎、AI 接入与 PDF 导出

上一篇:问卷考试系统设计(五):前端引擎

数据收集、评分计算都做完了,业务方更关心的是末尾一步:拿到一份类似样的报告。报告生成的完整链路:模板定结构、规则填数据、AI 写解读、图表画可视化、末尾拼成 PDF。

报告生成适合异步化,前端只订阅任务进度,不直接等待 PDF 渲染完成:

sequenceDiagram participant API as 报告 API participant Queue as 异步任务 participant Rule as 规则解读 participant AI as AI 文案 participant Chart as 图表渲染 participant PDF as PDF 导出 participant SSE as SSE 进度 API->>Queue: 提交评分结果 Queue->>SSE: 10% 已接收 Queue->>Rule: 生成规则解读 Queue->>AI: 生成辅助文案 Queue->>Chart: 渲染图表 PNG Queue->>PDF: 拼 HTML 并导出 PDF Queue->>SSE: 100% 报告可下载

报告生成的几种模式

模板填充适合格式固定的报告,模板写好,变量一填就完事。

规则引擎解决条件判断问题。焦虑得分 0-4 分是"无焦虑",5-9 分是"轻度焦虑",这种区间映射用硬编码也能写,但规则多了维护起来是噩梦。

AI 生成处理自然语言解读。数字摆在那里,但用户要的是一段"读得懂"的文字。

实际项目中三者结合:模板定骨架,规则引擎填确定性结论,AI 只负责把结论写得更类似人话。先保证可复现,再谈润色。


一、报告模板引擎

报告结构

一份典型的问卷报告包含:

报告头部(标题、被试信息、日期)
├── 综合得分概览
├── 各维度得分明细(得分 + 等级 + 解读)
├── 图表区(雷达图、柱状图)
├── AI 综合分析(可选)
└── 附录(原始答题数据)

模板 DSL 设计

设计一套简洁的模板语法:{{ }} 做变量插值,{% %} 做条件和循环:

public class ReportTemplateEngine {

    private static final Pattern VAR_PATTERN = Pattern.compile("\\{\\{\\s*(.+?)\\s*}}");

    public String render(String template, ReportContext context) {
        // 先处理条件/循环块
        String processed = processBlocks(template, context);
        // 再做变量替换
        Matcher m = VAR_PATTERN.matcher(processed);
        StringBuilder sb = new StringBuilder();
        while (m.find()) {
            String varName = m.group(1).trim();
            Object value = resolveVariable(varName, context);
            m.appendReplacement(sb, Matcher.quoteReplacement(
                value != null ? HtmlUtils.htmlEscape(value.toString()) : ""
            ));
        }
        m.appendTail(sb);
        return sb.toString();
    }

    private Object resolveVariable(String path, ReportContext context) {
        String[] parts = path.split("\\.");
        Object current = context.getData();
        for (String part : parts) {
            if (current instanceof Map) {
                current = ((Map<?, ?>) current).get(part);
            } else {
                return null;  // 找不到就返回空,不报错
            }
        }
        return current;
    }
}

默认对变量做 HTML 转义,避免姓名、备注这类用户输入把报告页面或 PDF 模板打穿。确实需要插入富文本时,要走单独的白名单字段,不能和普通变量混用。

变量解析支持点号路径:{{ user.name }} 会逐层从 Map 里取值。找不到返回空串,报告渲染不能因为某个字段缺失就抛异常,优雅降级,至少其他内容还能正常展示。

条件渲染

private String processBlocks(String template, ReportContext ctx) {
    StringBuilder result = new StringBuilder();
    String[] lines = template.split("\n");
    boolean showing = true;
    boolean conditionMet = false;

    for (String line : lines) {
        if (line.trim().startsWith("{% if")) {
            String condition = extractCondition(line);
            showing = evaluateCondition(condition, ctx);
            conditionMet = showing;
            continue;
        }
        if (line.trim().startsWith("{% elif")) {
            if (!conditionMet) {
                showing = evaluateCondition(extractCondition(line), ctx);
                conditionMet = showing;
            } else { showing = false; }
            continue;
        }
        if (line.trim().startsWith("{% else")) {
            showing = !conditionMet;
            continue;
        }
        if (line.trim().startsWith("{% endif")) {
            showing = true; conditionMet = false;
            continue;
        }
        if (showing) result.append(line).append("\n");
    }
    return result.toString();
}

模板用起来是这样的:

综合得分:{{ totalScore }}分
{% if totalScore >= 90 }
等级:优秀 -- 心理健康状态良好,请继续保持。
{% elif totalScore >= 70 }
等级:良好 -- 整体状态不错,部分维度有提升空间。
{% else %}
等级:需关注 -- 建议寻求专业心理咨询支持。
{% endif %}

非开发人员也能维护。改措辞不用动代码,改规则不用动模板。


二、分数解读规则引擎

规则 DSL

{
  "rules": [
    {
      "name": "anxiety_level",
      "dimension": "anxiety",
      "scoreField": "anxietyScore",
      "ranges": [
        { "min": 0,  "max": 4,  "level": "无焦虑",   "color": "#52c41a", "desc": "焦虑水平在正常范围内" },
        { "min": 5,  "max": 9,  "level": "轻度焦虑", "color": "#faad14", "desc": "存在轻度焦虑倾向" },
        { "min": 10, "max": 14, "level": "中度焦虑", "color": "#fa8c16", "desc": "建议寻求专业评估" },
        { "min": 15, "max": 21, "level": "重度焦虑", "color": "#f5222d", "desc": "强烈建议专业干预" }
      ]
    },
    {
      "name": "composite_assessment",
      "type": "composite",
      "dimensions": ["anxiety", "depression", "stress"],
      "weights": [0.4, 0.35, 0.25],
      "ranges": [
        { "min": 0,  "max": 50, "level": "整体健康", "desc": "各项指标均在正常范围" },
        { "min": 51, "max": 75, "level": "需要关注", "desc": "部分指标偏高" },
        { "min": 76, "max": 100, "level": "建议干预", "desc": "多项指标异常" }
      ]
    }
  ]
}

规则引擎实现

@Service
public class ScoreRuleEngine {

    public EvaluationResult evaluate(AnswerSheet sheet, RuleConfig config) {
        EvaluationResult result = new EvaluationResult();
        for (ScoreRule rule : config.getRules()) {
            if ("composite".equals(rule.getType())) {
                result.addComposite(evalComposite(sheet, rule));
            } else {
                result.addDimension(evalDimension(sheet, rule));
            }
        }
        return result;
    }

    private DimensionResult evalDimension(AnswerSheet sheet, ScoreRule rule) {
        double score = sheet.getScore(rule.getScoreField());
        for (ScoreRange range : rule.getRanges()) {
            if (score >= range.getMin() && score <= range.getMax()) {
                return DimensionResult.builder()
                    .dimension(rule.getDimension())
                    .score(score)
                    .level(range.getLevel())
                    .color(range.getColor())
                    .description(range.getDesc())
                    .build();
            }
        }
        return DimensionResult.builder()
            .dimension(rule.getDimension()).score(score).level("未知").build();
    }
}

区间映射用遍历而不是 if-else,规则数量可以随意扩展。业务方要调区间?改 JSON 就行,不用发版。


三、AI 文字解读

System Prompt 设计

private static final String SYSTEM_PROMPT = """
    你是心理测评报告撰写助手。只能依据已给出的评分、等级和规则说明写解读,
    不要做医学诊断,也不要补充不存在的量表结论。

    要求:
    1. 语言专业但易懂,避免过度医学术语
    2. 先总结整体状态,再分维度解读
    3. 给出具体、可操作的建议(3-5条)
    4. 如有维度得分较高(超过阈值),需特别指出并给出风险提示
    5. 明确说明报告仅供参考,不能替代专业诊断
    6. 结尾给出积极鼓励的语句
    7. 字数控制在 300-500 字
    8. 不要使用 markdown 格式

    输出格式:
    【整体评估】一段话
    【各维度分析】每个维度一段话
    【改善建议】1. 2. 3.
    【总结寄语】一段话
    """;

每一条都是踩坑之后加的:

  • “避免过度医学术语”:不加的话 AI 输出"被试的焦虑因子得分显著高于常模,建议进行认知行为干预",这不是给普通用户看的
  • “超过阈值需特别指出”:不加限制的话,AI 倾向于给所有用户输出正面评价,即使是重度焦虑也说"整体还不错"
  • “字数 300-500 字”:A/B 测试出来的,超过 500 字用户跳读率明显上升

用户消息组装

private String buildUserMessage(ReportContext ctx) {
    StringBuilder sb = new StringBuilder();
    sb.append("被试信息:").append(ctx.getUserName())
      .append(",年龄").append(ctx.getAge())
      .append(",测试日期:").append(ctx.getTestDate()).append("\n\n");
    sb.append("各维度得分:\n");

    for (DimensionResult dim : ctx.getDimensions()) {
        sb.append(String.format("- %s:%.1f分(等级:%s,参考说明:%s)\n",
            dim.getDimension(), dim.getScore(),
            dim.getLevel(), dim.getDescription()));
    }
    return sb.toString();
}

注意把每个维度的"参考说明"也传给了 AI。这个参考说明来自规则引擎的区间定义,AI 不会凭空编造等级描述,而是在规则引擎结论的基础上做扩展和深化。

同步 vs 流式生成

短报告(300 字以内)可以同步生成。但中长报告必须用流式,用户盯着空白页面等 5 秒以上,体验就崩了。

SSE 流式输出的后端实现:

@GetMapping(value = "/stream/{sessionId}",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamReport(@PathVariable String sessionId) {
    SseEmitter emitter = new SseEmitter(120_000L);  // 120秒超时

    CompletableFuture.runAsync(() -> {
        try {
            ReportContext ctx = reportContextService.getBySession(sessionId);
            String userMessage = aiReportService.buildUserMessage(ctx);

            openAiClient.streamChatCompletion(
                ChatCompletionRequest.builder()
                    .model("gpt-4o-mini")
                    .messages(List.of(
                        new Message("system", AIReportService.SYSTEM_PROMPT),
                        new Message("user", userMessage)
                    ))
                    .temperature(0.7)
                    .build()
            ).subscribe(
                chunk -> {
                    String delta = chunk.getChoices().get(0).getDelta().getContent();
                    if (delta != null) {
                        emitter.send(SseEmitter.event()
                            .name("chunk").data(Map.of("text", delta)));
                    }
                },
                emitter::completeWithError,
                () -> emitter.complete()
            );
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });

    return emitter;
}

前端用 EventSource 接收:

const source = new EventSource('/api/report/stream/' + sessionId);
source.addEventListener('chunk', (e) => {
  const { text } = JSON.parse(e.data);
  document.getElementById('ai-report').textContent += text;
});
source.addEventListener('done', () => source.close());

几个坑:

  • SseEmitter 超时:设到 120 秒,前端做断线重连
  • CompletableFuture 线程池:默认 ForkJoinPool 会被占满,建议自定义线程池
  • 错误处理:AI 返回一半断了,前端会停在半截文字上,需要给前端发 error 事件

temperature 参数

报告解读我一般从 0.5-0.7 开始试。低于 0.3 每次生成高度相似,用户容易觉得系统只是套了几段固定话术;高于 1.0 又容易“放飞自我”,偶尔编造不存在的心理学概念。末尾取值要看量表严肃程度和审核要求。

AI 辅助解读

不是所有报告都需要 AI 辅助解读。我的做法是:常规维度先用规则文案兜底,只有需要展开解释的异常维度才调用 AI,而且只做风险提示,不能替代诊断:

public InterpretationResult interpretAbnormal(List<DimensionResult> abnormalDimensions,
                                               ReportContext ctx) {
    if (abnormalDimensions.isEmpty()) {
        return InterpretationResult.builder()
            .needed(false)
            .message("所有指标均在正常范围内").build();
    }

    String aiText = callAI(INTERPRETATION_PROMPT, buildInterpretationMessage(abnormalDimensions, ctx));

    return InterpretationResult.builder()
        .needed(true)
        .aiText(aiText)
        .disclaimer(DISCLAIMER)
        .build();
}

免责声明写在代码里,不走 Prompt:

private static final String DISCLAIMER = """
    ⚠️ 免责声明
    以上分析由 AI 辅助生成,仅供参考。本报告不构成医学诊断或治疗建议。
    如您感到明显困扰或存在紧急风险,请及时联系线下专业机构、当地心理援助热线或急救服务。
    """;

越是敏感的信息,越要用确定性的方式管理。

降级机制

AI 服务不可用时,回退到规则引擎的静态文案:

public String generateInterpretationWithFallback(ReportContext ctx) {
    try {
        return aiReportService.generateInterpretation(ctx);
    } catch (Exception e) {
        log.warn("AI 生成失败,回退到静态文案: {}", e.getMessage());
        return fallbackGenerator.generate(ctx);
    }
}

这个 try-catch 很实用。大模型服务不稳定时,用户至少还能拿到规则引擎生成的报告,不会卡在空白页。


四、图表渲染

为什么用后端渲染

PDF 生成库不认识 JavaScript,它只认图片。前端用 ECharts 渲染图表当然可以,但 PDF 场景需要拿到图片文件。用 Puppeteer 跑一个轻量 Node 服务,接收 ECharts 配置、渲染成 PNG 返回:

app.post('/render-chart', async (req, res) => {
    const { option, width = 600, height = 400 } = req.body;
    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width, height });
    await page.setContent(`
        <div id="chart" style="width:${width}px;height:${height}px"></div>
        <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
        <script>
            var chart = echarts.init(document.getElementById('chart'));
            chart.setOption(${JSON.stringify(option)});
        </script>
    `);
    const imageBuffer = await page.$('#chart').then(el => el.screenshot({ type: 'png' }));
    await browser.close();
    res.type('png').send(imageBuffer);
});

雷达图渲染(Node.js + node-canvas)

const echarts = require('echarts');
const { createCanvas } = require('canvas');

function renderRadarChart(dimensions, scores, maxScore = 100) {
  const canvas = createCanvas(600, 400);
  const chart = echarts.init(canvas, null, { renderer: 'canvas' });

  const option = {
    title: { text: '各维度得分', left: 'center', top: 10 },
    radar: {
      indicator: dimensions.map(d => ({ name: d.label, max: maxScore })),
      radius: '65%'
    },
    series: [{
      type: 'radar',
      data: [{
        value: scores,
        areaStyle: { opacity: 0.2 },
        itemStyle: { color: '#1890ff' }
      }]
    }]
  };

  chart.setOption(option);
  const buffer = canvas.toBuffer('image/png');
  chart.dispose();
  return buffer.toString('base64');
}

图表嵌入报告很简单,替换模板里的占位符:

String radarBase64 = chartRenderer.renderRadar(dimensions, scores);
String html = templateEngine.render(template, context);
html = html.replace("{{chart:radar}}",
    "<img src='data:image/png;base64," + radarBase64 + "' />");

五、PDF 导出

iTextPDF vs Flying Saucer

特性iTextPDFFlying Saucer
原理Java 原生 API 构建 PDFHTML+CSS 转 PDF
中文支持需手动注册字体依赖 CSS @font-face
图表嵌入手动 drawImageimg 标签直接支持
适合场景复杂精确排版HTML 报告直接转 PDF

我们选 Flying Saucer,报告已经是 HTML 了,直接转更省事。

中文字体处理:常见的坑

PDF 导出十个 bug 里有八个跟字体有关。三个要点:

1. 字体文件必须打包进部署包,不能依赖服务器系统字体:

resources/fonts/SimHei.ttf

2. CSS 里要用具体字体名

body {
  font-family: "SimHei", "Microsoft YaHei", "PingFang SC", sans-serif;
  font-size: 14px;
  line-height: 1.8;
}

3. Java 代码里显式注册字体

ITextRenderer renderer = new ITextRenderer();
String fontPath = getClass().getResource("/fonts/SimHei.ttf").getPath();
renderer.getFontResolver().addFont(fontPath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);

BaseFont.IDENTITY_H 告诉 Flying Saucer 用 Unicode 水平编码。BaseFont.EMBEDDED 把字体嵌入 PDF,接收方电脑上没装 SimHei 也能正常显示。

完整 PDF 生成流程

public byte[] generatePdf(ReportContext ctx) {
    // 1. 生成图表
    Map<String, String> charts = renderCharts(ctx);

    // 2. 渲染 HTML 报告
    String html = templateEngine.render(loadTemplate("report-template.html"), ctx);
    html = embedCharts(html, charts);

    // 3. HTML → PDF
    return htmlToPdf(html);
}

页眉页脚

用 CSS 的 @page 指令,Flying Saucer 原生支持:

@page {
    @top-center {
        content: "心理测评报告 - 机密";
        font-size: 10px;
        color: #999;
    }
    @bottom-center {
        content: "第 " counter(page) " 页,共 " counter(pages) " 页";
        font-size: 10px;
    }
}

六、异步生成 + SSE 进度通知

报告生成涉及模板渲染、图表绘制、AI 调用,耗时从几秒到几十秒不等。正确做法是提交任务、异步执行、通过 SSE 实时推送进度。

public String submitTask(Long questionnaireId, Long userId) {
    String taskId = UUID.randomUUID().toString();

    CompletableFuture.runAsync(() -> {
        try {
            updateProgress(taskId, "GENERATING", 10, "正在计算分数...");
            ReportContext ctx = buildContext(questionnaireId, userId);

            updateProgress(taskId, "GENERATING", 30, "正在渲染图表...");
            Map<String, String> charts = chartRenderer.renderAll(ctx);

            updateProgress(taskId, "GENERATING", 60, "正在生成 AI 解读...");
            String aiText = aiReportService.generateInterpretation(ctx);

            updateProgress(taskId, "GENERATING", 80, "正在生成 PDF...");
            byte[] pdf = pdfReportService.generatePdf(ctx, charts, aiText);

            String fileUrl = fileStorage.upload(pdf, "report_" + taskId + ".pdf");
            updateProgress(taskId, "COMPLETED", 100, "报告生成完成");
        } catch (Exception e) {
            updateProgress(taskId, "FAILED", -1, "生成失败:" + e.getMessage());
        }
    }, reportExecutor);

    return taskId;
}

进度存 Redis 是为了容错,SSE 连接不稳定,断了重连后前端可以查 Redis 拿到当前进度,不用从头再来。

缓存策略

// report:{questionnaireId}:{userId}:{version}
public String cacheKey(Long questionnaireId, Long userId) {
    int version = reportVersionService.getCurrentVersion(questionnaireId);
    return String.format("report:%d:%d:v%d", questionnaireId, userId, version);
}

缓存 key 里带 version,运营改了评分规则,bump 一下 version,旧缓存自动失效。TTL 设 24 小时。


小结

验收时分别检查模板变量、规则结果、AI 边界、图表数据和 PDF 渲染。任一阶段失败都要保留可诊断状态,不能用一份空白或缺页的 PDF 覆盖失败记录。

关键设计决策:

  • 模板引擎:变量插值 + 条件渲染,非开发人员也能维护
  • 规则引擎:区间映射用 JSON 配置,改规则不改代码
  • AI 接入:Prompt、边界控制和降级机制都要一起设计
  • PDF 导出:字体打包进部署包,CSS 用具体字体名,代码里显式注册
  • 异步生成:SSE 推送进度,Redis 存进度做容错

下一篇聊数据统计与可视化,分组统计、T 检验、大屏可视化,以及整个系列的总结。

上一篇:问卷考试系统设计(五):前端引擎 下一篇:问卷考试系统设计(七):数据统计、可视化与系列总结