智契通项目开发周记(第六周):版本回滚、文本对比、导出归档与 AI 调用记录
一、本周工作概述
第五周已经完成合同台账、合同详情、上传导入和编辑保存新版本,系统具备了合同管理的基本入口和版本快照机制。第六周的重点是在版本基础上继续完善合同生命周期闭环,让用户不仅能保存多个版本,还能查看历史版本、回滚到指定版本、对比两个版本差异,并将合同导出或归档。同时,本周还补充了 AI 调用日志,使智能生成和 AI 助手的模型调用过程具备基本追踪能力。
本周完成的主要工作包括:
完成历史版本列表接口与前端展示。
完成版本详情接口,支持查看任意历史版本正文。
完成版本回滚功能,回滚不会覆盖当前版本,而是基于目标版本生成新的 rollback 版本。
完成版本文本对比功能,支持识别新增、删除、修改内容,并计算文本相似度。
完成合同 Word 导出功能,支持导出最新版本或指定历史版本。
完成合同归档功能,将合同状态更新为 archived。
完成 AI 调用日志能力,记录任务类型、模型、Prompt 版本、调用状态、耗时和错误信息。
同步完善 README、接口文档、数据库设计、开发文档和流程图文档。
本周完成后,合同管理模块已经从“合同列表 + 正文编辑”扩展为“合同版本闭环 + 输出归档 + AI 可追踪”的形态。
二、版本管理:查询、详情与回滚
合同版本管理的核心原则是:历史版本不可变。无论用户编辑正文还是执行回滚,都不应该修改已有版本的内容。回滚操作看起来像是“恢复旧版本”,但在审计意义上,它本质上是一次新的操作,应当形成新的版本记录。
因此本周实现回滚时,没有把 latest_version_id 直接指回旧版本,而是复制目标版本正文,创建一个新的版本,新版本的 source_type 为 rollback,父版本指向回滚前的最新版本。这样版本链条是连续的,可以清楚表达“当前版本是由哪个版本回滚而来”。
核心代码如下:
@Transactional
public VersionRollbackResponse rollback(VersionRollbackRequest request) {
ContractInfo contract = contractInfoRepository.findById(request.contractId())
.orElseThrow(() -> new IllegalArgumentException("合同不存在"));
ContractVersion targetVersion = contractVersionRepository.findById(request.targetVersionId())
.orElseThrow(() -> new IllegalArgumentException("目标版本不存在"));
if (!contract.getId().equals(targetVersion.getContractId())) {
throw new IllegalArgumentException("目标版本不属于当前合同");
}
ContractVersion latestVersion = resolveLatestVersion(contract);
if (latestVersion != null && latestVersion.getId().equals(targetVersion.getId())) {
throw new IllegalArgumentException("目标版本已经是当前最新版本,无需回滚");
}
ContractVersion rollbackVersion = new ContractVersion();
rollbackVersion.setContractId(contract.getId());
rollbackVersion.setVersionNo(latestVersion == null ? 1 : latestVersion.getVersionNo() + 1);
rollbackVersion.setParentVersionId(latestVersion == null ? null : latestVersion.getId());
rollbackVersion.setContent(targetVersion.getContent());
rollbackVersion.setContentHash(sha256(targetVersion.getContent()));
rollbackVersion.setSourceType("rollback");
rollbackVersion.setChangeSummary(buildRollbackSummary(request.changeSummary(), targetVersion));
rollbackVersion.setCreatedBy(1L);
rollbackVersion.setCreatedAt(new Date());
ContractVersion savedVersion = contractVersionRepository.save(rollbackVersion);
contract.setLatestVersionId(savedVersion.getId());
contract.setUpdatedAt(new Date());
contractInfoRepository.save(contract);
return new VersionRollbackResponse(
contract.getId(),
savedVersion.getId(),
savedVersion.getVersionNo(),
savedVersion.getParentVersionId(),
targetVersion.getId(),
savedVersion.getSourceType(),
savedVersion.getChangeSummary(),
savedVersion.getContent(),
savedVersion.getCreatedAt()
);
}
这个设计避免了一个常见问题:如果回滚只是把合同最新版本指向旧版本,虽然页面上看起来恢复了内容,但系统无法记录“什么时候发生了回滚”。现在通过新增版本,回滚行为本身也成为历史的一部分。

三、文本对比:先做确定性 diff,再考虑语义增强
项目需求中提到了多版本语义对比,但语义对比需要更复杂的条款切分、向量化、相似度匹配和模型解释。为了先完成可演示、可验证的版本对比能力,本周实现了文本差异对比第一阶段。
当前对比逻辑采用行级文本处理。系统先把左右版本正文按行切分,去除空行,然后通过最长公共子序列思想寻找左右文本中的相同行。相同行之间的间隔被视为差异片段。如果左侧为空、右侧有内容,则是新增;如果左侧有内容、右侧为空,则是删除;如果左右都有内容但不完全相同,则视为修改,并通过 Levenshtein 编辑距离计算相似度。
核心代码如下:
private List<CompareDiffItemVO> buildTextDiff(String leftContent, String rightContent) {
List<String> leftLines = splitContent(leftContent);
List<String> rightLines = splitContent(rightContent);
List<LineMatch> matches = findExactMatches(leftLines, rightLines);
List<CompareDiffItemVO> items = new ArrayList<>();
int leftStart = 0;
int rightStart = 0;
int clauseSeq = 1;
for (LineMatch match : matches) {
clauseSeq = appendSegmentDiff(
items,
leftLines.subList(leftStart, match.leftIndex()),
rightLines.subList(rightStart, match.rightIndex()),
clauseSeq
);
leftStart = match.leftIndex() + 1;
rightStart = match.rightIndex() + 1;
}
appendSegmentDiff(
items,
leftLines.subList(leftStart, leftLines.size()),
rightLines.subList(rightStart, rightLines.size()),
clauseSeq
);
return items;
}
相似度计算使用编辑距离:
private double similarityScore(String leftText, String rightText) {
String left = normalizeLine(leftText);
String right = normalizeLine(rightText);
if (left.isEmpty() && right.isEmpty()) {
return 100.0;
}
if (left.isEmpty() || right.isEmpty()) {
return 0.0;
}
int maxLength = Math.max(left.length(), right.length());
int distance = levenshtein(left, right);
double score = (1.0 - (double) distance / maxLength) * 100;
return Math.round(Math.max(score, 0.0) * 10.0) / 10.0;
}
这一版对比并不宣称已经完成语义理解,但它有明确价值:结果稳定、便于测试、能够快速展示合同版本之间的文本变化。后续如果引入语义对比,可以在此基础上增加条款编号识别、向量相似度和 LLM 解释,而不是直接推翻现有结构。
四、导出与归档:合同生命周期的输出环节
合同管理不能只停留在系统内查看,用户最终需要把合同输出为文件,或者将合同状态固定为归档。因此本周增加了 Word 导出和归档功能。
导出接口支持两种模式:如果传入 versionId,则导出指定版本;如果不传,则导出合同最新版本。后端使用 Apache POI 创建 .docx 文件,并写入合同标题、合同编号、分类、甲乙方、金额、期限、版本号和正文内容。
核心代码如下:
public ExportContractFile exportDocx(Long contractId, Long versionId) {
ContractInfo contract = contractInfoRepository.findById(contractId)
.orElseThrow(() -> new IllegalArgumentException("合同不存在"));
ContractVersion version = resolveVersion(contract, versionId);
byte[] data = buildDocx(contract, version);
String fileName = sanitizeFileName(contract.getContractName()) + "-v" + version.getVersionNo() + ".docx";
return new ExportContractFile(fileName, DOCX_CONTENT_TYPE, data);
}
private byte[] buildDocx(ContractInfo contract, ContractVersion version) {
try (XWPFDocument document = new XWPFDocument();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
addTitle(document, contract.getContractName());
addMeta(document, contract, version);
addContent(document, version.getContent());
document.write(outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new IllegalStateException("合同导出失败", e);
}
}
归档功能相对简单,只是将合同状态更新为 archived。但从业务语义上看,它代表合同生命周期进入稳定阶段。归档后仍然允许查看和导出,但后续如果增加权限控制,可以限制归档合同继续编辑。
五、AI 调用日志:让模型调用可追踪
前几周已经完成 AI 助手和智能合同生成,但如果模型调用失败、耗时异常或输出质量不稳定,系统缺少追踪依据。因此本周增加了 AI 调用日志表和服务,将 AI 助手、合同生成等调用统一记录下来。
本次设计没有把日志记录写死在某一个业务模块,而是在 AiChatService 中统一处理。无论是通用问答还是合同生成,只要经过 chatWithPrompt,都会记录任务类型、模型、Prompt 版本、系统 Prompt、用户 Prompt、响应文本、调用状态和耗时。
核心代码如下:
public String chatWithPrompt(String systemPrompt, String userPrompt, String taskType, String promptVersion) {
long startedAt = System.currentTimeMillis();
String normalizedSystemPrompt = systemPrompt == null || systemPrompt.isBlank() ? "你是专业合同助手。" : systemPrompt;
String normalizedUserPrompt = userPrompt == null ? "" : userPrompt;
if (properties.apiKey() == null || properties.apiKey().isBlank()) {
aiCallLogService.recordQuietly(
normalizeTaskType(taskType),
properties.model(),
normalizePromptVersion(promptVersion),
normalizedSystemPrompt,
normalizedUserPrompt,
"",
"failed",
"AI_API_KEY 未配置",
System.currentTimeMillis() - startedAt
);
throw new IllegalStateException("AI_API_KEY 未配置");
}
try {
String response = restClient.post()
.uri("/v1/chat/completions")
.body(body)
.retrieve()
.body(String.class);
String answer = extractAnswer(response);
aiCallLogService.recordQuietly(
normalizeTaskType(taskType),
properties.model(),
normalizePromptVersion(promptVersion),
normalizedSystemPrompt,
normalizedUserPrompt,
answer,
"success",
"",
System.currentTimeMillis() - startedAt
);
return answer;
} catch (Exception e) {
aiCallLogService.recordQuietly(
normalizeTaskType(taskType),
properties.model(),
normalizePromptVersion(promptVersion),
normalizedSystemPrompt,
normalizedUserPrompt,
"",
"failed",
e.getMessage(),
System.currentTimeMillis() - startedAt
);
throw e;
}
}
日志服务中的一个设计细节是 recordQuietly。它会捕获日志写入异常,避免因为日志保存失败影响主业务流程:
public void recordQuietly(
String taskType,
String modelName,
String promptVersion,
String systemPrompt,
String userPrompt,
String responseText,
String status,
String errorMessage,
Long elapsedMs
) {
try {
AiCallLog log = new AiCallLog();
log.setTaskType(taskType);
log.setModelName(modelName);
log.setPromptVersion(promptVersion);
log.setSystemPrompt(systemPrompt);
log.setUserPrompt(userPrompt);
log.setResponseText(responseText);
log.setStatus(status);
log.setErrorMessage(normalizeText(errorMessage, 1000));
log.setElapsedMs(elapsedMs);
log.setCreatedBy(1L);
log.setCreatedAt(new Date());
aiCallLogRepository.save(log);
} catch (Exception ignored) {
// AI call logging must not block the primary business flow.
}
}
这种处理符合日志系统的定位:日志很重要,但不应该反过来阻断合同生成或 AI 问答。系统管理页也增加了 AI 调用日志查询,可以按任务类型、状态和关键词筛选,便于后续调试 Prompt 和排查模型问题。
六、文档同步与阶段总结
本周最后同步完善了 README、接口文档、数据库设计、开发文档和流程图。文档更新的重点不是简单把接口罗列一遍,而是让设计文档与当前实现保持一致。例如接口文档中补充了 GET /api/contract/export、POST /api/contract/{id}/archive、GET /api/version/{versionId}、GET /api/ai/logs;数据库设计中补充了 contract_attachment、ai_call_log 和 rollback 版本来源;流程图中补充了合同管理与版本流转流程。
第六周完成后,合同管理模块已经形成比较完整的闭环:合同可以通过 AI 生成或上传导入进入系统;进入系统后可以在台账中查询和查看详情;正文编辑会形成历史版本;用户可以查看版本、回滚版本、对比差异;最终可以导出 Word 或归档合同;AI 调用也具备日志留痕。
从技术理解上看,本周最大的收获是明确了三个边界:
第一,回滚不是覆盖旧数据,而是一次新的版本操作。
第二,文本对比可以先做确定性算法,再逐步增强为语义对比。
第三,AI 能力接入不仅要能调用模型,也要能记录调用过程,方便审计、调试和效果评估。
后续项目可以继续补充语义向量对比、对比结果持久化、PDF 导出、真实知识库检索、风险审查和摘要润色模块。当前合同管理与智能生成主线已经具备可演示、可扩展的基础。

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)