AgentScope-Java 入门:保存评审状态并生成 Markdown 报告
一句话导语:上一篇我们已经能读取 Git diff 并生成规则化 findings。这一篇把评审任务、发现项和 Markdown 报告保存下来,让结果可以查询、刷新和复制。
系列说明
本文是「AgentScope-Java 2.0.0-RC4 项目式入门」系列第 4 篇。
本篇对应:
| 项目 | 内容 |
|---|---|
| 起点 tag | chapter-03-complete |
| 分支 | chapter/04-review-state |
| 完成 tag | chapter-04-complete |
| 目标 | 保存 ReviewJob、生成 Markdown 报告,并提供查询和报告下载接口 |
阅读本文你将完成:
ReviewJobStore抽象。- JSON 文件持久化实现。
GET /api/reviews/{id}。GET /api/reviews/{id}/report.md。ReportTool和ReviewReportService。- 前端结果页和报告复制。
为什么要先做状态
评审任务不是一次普通 HTTP 调用。
它有生命周期:
text
CREATED -> RUNNING -> COMPLETED
└-> FAILED
如果任务状态只存在内存里,就会有几个问题:
- 页面刷新后结果丢失。
- 后端重启后无法查看历史报告。
- 前端无法通过 jobId 再次加载结果。
- 报告无法作为文章、PR 评论或团队记录复用。
所以从这一篇开始,项目要有明确的状态存储和报告输出。
状态对象 ReviewJob
ReviewJob 是整个项目的核心状态对象:
java
// ReviewJob.java
public record ReviewJob(
String id,
String sessionId,
String repoPath,
DiffMode diffMode,
String baseRef,
List<ReviewCategory> focusCategories,
ReviewStatus status,
Instant createdAt,
Instant startedAt,
Instant finishedAt,
List<ReviewFinding> findings,
String reportPath,
String errorMessage) {
}
它不是只给前端展示的 DTO,而是评审任务的事实来源。
状态流转方法建议写在 record 里:
java
// ReviewJob.java
public ReviewJob running() {
return new ReviewJob(
id, sessionId, repoPath, diffMode, baseRef, focusCategories,
ReviewStatus.RUNNING, createdAt, Instant.now(), null,
findings, reportPath, null);
}
public ReviewJob completed(List<ReviewFinding> nextFindings, String nextReportPath) {
return new ReviewJob(
id, sessionId, repoPath, diffMode, baseRef, focusCategories,
ReviewStatus.COMPLETED, createdAt, startedAt, Instant.now(),
List.copyOf(nextFindings), nextReportPath, null);
}
public ReviewJob failed(String errorMessage) {
return new ReviewJob(
id, sessionId, repoPath, diffMode, baseRef, focusCategories,
ReviewStatus.FAILED, createdAt, startedAt, Instant.now(),
findings, reportPath, errorMessage);
}
设计要点 :让状态流转通过方法完成,比在 Service 里到处手写构造参数更安全。字段越多,手写构造越容易传错。
running()、completed()、failed()三个方法覆盖了CREATED → RUNNING → COMPLETED/FAILED的全部流转,Service 层不需要直接构造ReviewJob。
定义 ReviewJobStore
先定义存储接口:
java
// ReviewJobStore.java
public interface ReviewJobStore {
ReviewJob save(ReviewJob job);
Optional<ReviewJob> findById(String id);
}
接口很小,只有保存和查询。
第一版不急着做列表、分页、删除、权限隔离。
因为入门项目的重点是跑通一条完整链路:
text
创建任务 -> 执行评审 -> 保存结果 -> 查询结果 -> 下载报告
用 JSON 文件做持久化
生产系统可以用 MySQL、Redis、对象存储。
但入门项目不应该一上来引入数据库。
我们先用 JSON 文件:
text
backend/data/
├── jobs/
│ └── review-20260706-200440-c5bda7f1.json
└── reports/
└── review-20260706-200440-c5bda7f1.md
实现:
java
// JsonFileReviewJobStore.java
public ReviewJob save(ReviewJob job) {
Path file = jobDir.resolve(job.id() + ".json");
objectMapper.writeValue(file.toFile(), job);
return job;
}
public Optional<ReviewJob> findById(String id) {
Path file = jobDir.resolve(id + ".json");
if (!Files.exists(file)) {
return Optional.empty();
}
return Optional.of(objectMapper.readValue(file.toFile(), ReviewJob.class));
}
这里要注意两点:
- 使用
JavaTimeModule,否则Instant序列化不友好。 - 查询时必须校验
id,避免路径穿越。
ReviewIdValidator:保护报告路径
一旦接口支持:
text
GET /api/reviews/{id}
GET /api/reviews/{id}/report.md
id 就来自 URL。
不要直接拿它拼路径:
java
// 不推荐
Path file = reportDir.resolve(id + ".md");
应该先限制格式:
java
// ReviewIdValidator.java
private static final Pattern REVIEW_ID =
Pattern.compile("review-[0-9]{8}-[0-9]{6}-[a-f0-9]{8}");
public String validate(String id) {
if (id == null || !REVIEW_ID.matcher(id).matches()) {
throw new IllegalArgumentException("非法评审任务 ID:" + id);
}
return id;
}
这类小工具很容易被忽略,但它决定了"本地文件存储"是否安全。
生成 Markdown 报告
报告由 ReportTool 渲染:
java
// ReportTool.java
public String renderMarkdown(ReviewJob job, String modelNote) {
StringBuilder markdown = new StringBuilder();
markdown.append("# AgentScope 代码评审报告").append(System.lineSeparator());
markdown.append("- 任务:`").append(job.id()).append("`").append(System.lineSeparator());
markdown.append("- 会话:`").append(job.sessionId()).append("`").append(System.lineSeparator());
markdown.append("- 仓库:`").append(job.repoPath()).append("`").append(System.lineSeparator());
markdown.append("## 摘要").append(System.lineSeparator());
markdown.append("共发现 **").append(job.findings().size()).append("** 条工程质量发现项。");
markdown.append("## 发现项").append(System.lineSeparator());
for (ReviewFinding finding : job.findings()) {
markdown.append("### ")
.append(finding.severity())
.append(" · ")
.append(finding.category().getCode())
.append(" · `")
.append(finding.file())
.append("`");
}
return markdown.toString();
}
报告不是给机器看的,而是给人看的。
所以它要有:
- 摘要。
- 任务信息。
- 每条 finding 的证据、影响、建议。
- 模型配置说明。
- 可复制的 Markdown 格式。
ReviewReportService:保存报告
ReviewReportService 负责把渲染和保存封装起来:
java
// ReviewReportService.java
public ReviewReport generateAndSave(ReviewJob job, String modelNote) {
String markdown = reportTool.renderMarkdown(job, modelNote);
Path reportPath = reportDir.resolve(job.id() + ".md");
Files.writeString(reportPath, markdown, StandardCharsets.UTF_8);
return new ReviewReport(job.id(), markdown, Instant.now());
}
它还负责读取报告:
java
public String readMarkdown(String id) {
String safeId = reviewIdValidator.validate(id);
Path reportPath = reportDir.resolve(safeId + ".md");
return Files.readString(reportPath, StandardCharsets.UTF_8);
}
接入 ReviewService
评审结束时:
java
// ReviewService.java
ReviewJob reportJob = job.completed(findings, null);
ReviewReport report = reportService.generateAndSave(reportJob, modelNote);
ReviewJob completed = jobStore.save(
job.completed(findings, "data/reports/" + job.id() + ".md"));
eventPublisher.publish(ReviewEvent.of(
job.id(),
ReviewEventType.REPORT_READY,
"Markdown 报告已生成。",
Map.of("reportLength", report.markdown().length())));
这里有个细节:先拿包含 findings 的 reportJob 生成报告,再保存最终 completed 状态。
这样报告和任务状态是一致的。
报告渲染不要只写摘要,也要把每条 finding 的证据、影响、建议和置信度渲染出来:
java
for (ReviewFinding finding : job.findings()) {
markdown.append("### ")
.append(finding.severity())
.append(" · ")
.append(finding.category().getCode())
.append(" · `")
.append(finding.file())
.append("`")
.append(System.lineSeparator())
.append(System.lineSeparator());
if (finding.line() != null) {
markdown.append("- 行号:").append(finding.line()).append(System.lineSeparator());
}
markdown.append("- 证据:").append(finding.evidence()).append(System.lineSeparator());
markdown.append("- 影响:").append(finding.impact()).append(System.lineSeparator());
markdown.append("- 建议:").append(finding.suggestion()).append(System.lineSeparator());
markdown.append("- 置信度:").append(finding.confidence()).append(System.lineSeparator());
markdown.append(System.lineSeparator());
}

查询接口
查询任务:
java
// ReviewController.java
@GetMapping("/api/reviews/{id}")
public Mono<ReviewJob> getReview(@PathVariable String id) {
return reviewService.getReview(id);
}
读取报告:
java
@GetMapping(value = "/api/reviews/{id}/report.md",
produces = "text/markdown;charset=UTF-8")
public Mono<String> readReport(@PathVariable String id) {
return reviewService.readReportMarkdown(id);
}
这两个接口让前端刷新页面后仍然可以恢复结果。
前端结果页
ReviewResultView 做三件事:
- 调用
GET /api/reviews/{id}加载任务。 - 调用
GET /api/reviews/{id}/report.md加载报告。 - 展示 findings 和 Markdown 报告。
Pinia store:
ts
// reviewStore.ts
async function load(id: string) {
currentJob.value = await loadReview(id)
return currentJob.value
}
async function loadMarkdown(id: string) {
reportMarkdown.value = await loadReport(id)
return reportMarkdown.value
}
async function copyReport() {
if (!reportMarkdown.value) return
await navigator.clipboard.writeText(reportMarkdown.value)
}
复制报告是一个小功能,但对真实使用很重要。
评审报告通常会被贴到:
- PR 评论。
- 任务系统。
- 团队群。
- 技术文章。
验证
运行完整验证:
powershell
.\scripts\verify.ps1 -SkipFrontendInstall
端到端 smoke:
powershell
.\scripts\smoke-review.ps1
预期输出:
json
{
"health": "UP",
"status": "COMPLETED",
"findingCount": 2,
"reportContainsModelNote": true
}
手动验证报告:
bash
curl http://localhost:8080/api/reviews/{id}/report.md
预期包含:
markdown
# AgentScope 代码评审报告
## 摘要
## 发现项
设计思考:AgentState 和业务状态不是一回事
AgentScope-Java 2.0 的核心状态是 AgentState。
它管理的是 Agent 内部状态:
- 对话上下文。
- 工具上下文。
- 权限上下文。
- 任务计划。
- 中断恢复。
而本项目的 ReviewJob 是业务状态:
- 评审任务 ID。
- 仓库路径。
- diff 范围。
- findings。
- reportPath。
- status。
两者不要混在一起。
| 状态 | 属于谁 | 用途 |
|---|---|---|
AgentState |
AgentScope 框架 | Agent 推理、工具、会话 |
ReviewJob |
业务应用 | 评审任务状态和结果 |
这也是做 Agent 应用时常见的分层问题:框架状态服务 Agent,业务状态服务产品。
常见问题
为什么不用数据库
入门项目优先保证零外部依赖。
JSON 文件足够支撑:
- 任务保存。
- 报告保存。
- 重启后查询。
后续企业化可以替换为 MySQL、Redis 或对象存储。
为什么报告保存为 Markdown
Markdown 是代码评审场景最通用的格式。
它可以直接复制到 GitHub/GitLab PR 评论,也可以放入知识库或工单系统。
为什么不把报告写到被评审仓库
因为本项目第一原则是:不修改被评审源码。
报告只写到 Review Copilot 自己的 backend/data/reports 目录。
本篇小结
这一篇完成了:
ReviewJobStore。JsonFileReviewJobStore。ReviewReportService。ReportTool。GET /api/reviews/{id}。GET /api/reviews/{id}/report.md。- 前端结果页。
- Markdown 报告一键复制。
下一篇,我们专门处理安全边界:仓库路径校验、敏感文件保护、报告写入边界,以及为什么入门阶段不做自动修复。
系列导航
| 篇目 | 标题 | 状态 |
|---|---|---|
| 1 | AgentScope-Java 入门:搭建 Review Copilot 项目骨架 | 已发布 |
| 2 | AgentScope-Java 入门:用 SSE 展示评审任务进度 | 已发布 |
| 3 | AgentScope-Java 入门:让评审助手读懂 Git diff | 已发布 |
| 4 | AgentScope-Java 入门:保存评审状态并生成 Markdown 报告 | 本文 |
| 5 | AgentScope-Java 入门:给代码评审助手加上只读安全边界 | 待发布 |
| 6 | AgentScope-Java 入门:用 Middleware 审计 Agent 调用 | 待发布 |
| 7 | AgentScope-Java 入门:完善 Vue 前端、发布 GitHub,并规划下一步 | 待发布 |
作者:亦暖筑序
系列仓库:获取方式见作者主页说明。
AgentScope-Java 版本:2.0.0-RC4