问卷考试系统设计(六):报告生成——模板引擎、AI 接入与 PDF 导出
上一篇:问卷考试系统设计(五):前端引擎
数据收集、评分计算都做完了,业务方更关心的是末尾一步:拿到一份类似样的报告。报告生成的完整链路:模板定结构、规则填数据、AI 写解读、图表画可视化、末尾拼成 PDF。
报告生成适合异步化,前端只订阅任务进度,不直接等待 PDF 渲染完成:
报告生成的几种模式
模板填充适合格式固定的报告,模板写好,变量一填就完事。
规则引擎解决条件判断问题。焦虑得分 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
| 特性 | iTextPDF | Flying Saucer |
|---|---|---|
| 原理 | Java 原生 API 构建 PDF | HTML+CSS 转 PDF |
| 中文支持 | 需手动注册字体 | 依赖 CSS @font-face |
| 图表嵌入 | 手动 drawImage | img 标签直接支持 |
| 适合场景 | 复杂精确排版 | HTML 报告直接转 PDF |
我们选 Flying Saucer,报告已经是 HTML 了,直接转更省事。
中文字体处理:常见的坑
PDF 导出十个 bug 里有八个跟字体有关。三个要点:
1. 字体文件必须打包进部署包,不能依赖服务器系统字体:
resources/fonts/SimHei.ttf2. 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 检验、大屏可视化,以及整个系列的总结。