一、本周工作概述

第五周已经完成合同台账、合同详情、上传导入和编辑保存新版本,系统具备了合同管理的基本入口和版本快照机制。第六周的重点是在版本基础上继续完善合同生命周期闭环,让用户不仅能保存多个版本,还能查看历史版本、回滚到指定版本、对比两个版本差异,并将合同导出或归档。同时,本周还补充了 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 导出、真实知识库检索、风险审查和摘要润色模块。当前合同管理与智能生成主线已经具备可演示、可扩展的基础。

Logo

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

更多推荐