AgentScope-Java 入门:保存评审状态并生成 Markdown 报告

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
  • ReportToolReviewReportService
  • 前端结果页和报告复制。

为什么要先做状态

评审任务不是一次普通 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

相关推荐
水如烟1 小时前
孤能子视角:三十六计之连环计——拓扑结构重构
人工智能
茶马古道的搬运工1 小时前
MiniMax Agent Team 深度解析:用对抗式多 Agent 架构解决长程任务
人工智能
程序猿编码1 小时前
没有AI框架,没有GPU,一个C语言文件跑通大模型
c语言·开发语言·人工智能·深度学习·ai·大模型
程序媛kelly1 小时前
.xml / .jrxml 文件怎么打开?OpenFiles 实测预览、编辑、搜索与 AI 摘要排查流程
xml·人工智能·jrxml
小弥儿1 小时前
GitHub今日热榜 | 2026-07-16:AI知识库与反AI味设计成今日双主线
人工智能·学习·github
invicinble1 小时前
关于微服务基本框架搭建--fegin最小使用demo
java·spring boot·微服务
梦想的旅途21 小时前
企业微信API 实现企业微信消息的自动发送?
java·服务器·前端
何时梦醒1 小时前
🤖 RAG 文档分割器(Splitter)深度学习笔记 —— 从零搭建知识库文档处理流水线
前端·javascript·人工智能
Csvn1 小时前
📊 SQL 入门 Day 4:聚合函数 — COUNT / SUM / AVG / MIN / MAX
后端·sql