Skip to the content.

AI 审核

AI 审核是分析报告之上的一层可选解释:静态分析已经算出”谁受影响、为什么、走哪条路径、有多确定”, 模型只负责把这些事实写成评审要点。默认关闭,关闭时报告里没有 ai 字段、分析流程与改动前一致、 不向任何外部服务发请求,分析页也不会多出任何元素。

为什么摘要只解释事实,逐条意见只看变更片段

把整个仓库丢给模型问”这次改动会影响谁”,得到的是看起来合理但无法核对的答案:它可能漏掉一条真实调用链, 也可能编出一个不存在的类。两种错误在报告里长得一模一样。

所以摘要只接收静态分析结论。可选的「逐条评审意见」是另一段独立调用,只接收本次 diff 的 变更片段:它评论片段里真实存在的代码,不替静态分析决定”影响了谁”,也看不到片段以外的文件。

所以这条链路的方向是固定的:

① 静态分析产出事实(变更、影响路径、每跳用法、风险、置信度、盲区)
        ↓ 只把结论摘要(开启逐条意见时还有变更片段)送出去
② 模型用评审语言复述事实、排出人工确认的优先级
        ↓ 单独存放
③ report.ai,界面单独一张卡片并标注「待验证」

三条边界由此确定:

模型能做的 模型不能做的
解释某条影响为什么值得先看 决定”影响了谁”(影响清单由引擎产出)
把技术路径翻译成评审语言 看到源码全文(只收到结论摘要与变更片段)
指出需要人工确认的点 新增事实中不存在的类、方法或调用关系
在给出的变更片段内指出具体问题 评论片段以外的代码,或把”无法验证”当成删除理由

最后一条写在提示词里:禁止编造事实里没有的类、方法或调用,不确定就写”需人工确认”。 但提示词不是保证,所以输出与证据分区存放——读者始终能分清哪部分是静态分析的结论、哪部分是模型的解释。

配置

两种方式,服务不同场景。

设置页(日常调整)

「设置 → AI 审核」卡片。改完点「保存」立即生效,不必重启。

项 默认 说明
启用 AI 审核 关 总开关
接口地址 空 需以 http:// 或 https:// 开头;尾部 / 会被去掉
模型 空 端点上的模型名
API Key 空 本地模型通常不需要
每条分析最多送审影响 12 1..50;条目越多提示词越长,费用与耗时也越高
请求超时(秒) 60 5..300
生成逐条评审意见 开 独立的第二段模型调用;发送变更片段而非源码全文(见「隐私边界」)
每条分析最多评论 12 1..50;超出上限按严重级截断,能定位到行的评论优先保留
用反思模型过滤误报 开 每次报告额外一次模型调用;只删除与代码事实矛盾的评论(见「误报过滤」)
默认在每次分析时生成 AI 审核 开 决定分析弹窗里那个勾选的初始状态

配置落在 {data-dir}/ai-settings.json(默认 ./.code-association/ai-settings.json,卡片底部显示绝对路径)。 写入是原子的(.tmp + ATOMIC_MOVE),POSIX 平台上尽量设为仅本人可读。

文件坏掉不影响启动。 读不到、解析失败、没有权限,一律退回 application.yml 的配置并记一条日志。 设置文件是使用者的便利,不该成为服务的启动依赖。

Key 只回显掩码。 长度不超过 8 时显示 ••••,否则显示前 4 字符 + … + 后 4 字符。 输入框的值永远是空的,掩码只出现在占位符里(「已保存 sk-a…3f9c · 留空保持不变」)。 保存时留空表示”不修改”,只有「清除」才真正清空——清除直接发一次 clearApiKey, 不做成”等下次保存才生效”,否则表单会显示已清除而服务端仍留着旧 Key。

为什么这一条必须写死在协议里:如果表单把掩码当明文回传,Key 会在第一次保存后被写成 sk-a…3f9c 这样的字符串,之后每次调用都 401,而界面上看不出任何异常——用户只会以为服务坏了。

application.yml(部署统一配置)

code-association:
  ai:
    enabled: true
    base-url: https://api.deepseek.com/v1     # 任意 OpenAI 兼容端点
    api-key: ${AI_API_KEY:}                   # 从环境变量读,不要写进仓库;缺省为空
    model: deepseek-chat
    max-impacts: 12                           # 1..50
    timeout-seconds: 60                       # 单次请求,5..300
    # total-budget-seconds: 180               # 整段 AI 的总预算;不写按 timeout-seconds × 3 推导(90..900)
    comments: true                            # 逐条评审意见(发送变更片段)
    max-comments: 12                          # 1..50
    reflection: true                          # 反思模型过滤事实错误
    default-for-runs: true

两者的关系:基线 + 快照覆盖

application.yml 是基线;一旦在设置页保存过,ai-settings.json 里那份完整快照会覆盖它。 想回到文件配置,删掉 ai-settings.json 重启。

当前生效值来自哪里,GET /api/ai 的 configuredBy 会回答(file 或 application.yml)。

覆盖而非逐项合并,是因为两个来源的语义不同:yml 属于部署形态,一份配置对所有实例生效; 设置页属于这台机器上的即时调整。合并会让”界面上改了却没生效”变成常态, 而删文件即回退保证了部署配置永远可复原。

就绪的判定

只有一个口径:已启用 + 接口地址非空 + 模型非空。

API Key 不算在内——本地 Ollama 一类的端点本来就没有 key。未就绪时 GET /api/ai 的 reason 给出缺了哪一项的中文原因(如”已启用但未填写模型”)。GET /api/health 的 aiReady 与 aiEnabled 报告同一件事,后者为兼容既有集成保留。

支持的端点

任意 OpenAI 兼容的 /chat/completions。地址去掉尾部 /,缺 /chat/completions 时自动补上, 所以填到 /v1 就够。

服务 接口地址 模型 备注
DeepSeek https://api.deepseek.com/v1 deepseek-chat 需要 API Key
OpenAI https://api.openai.com/v1 gpt-4o-mini 需要 API Key
Ollama 本地 http://localhost:11434/v1 qwen2.5:7b Key 留空;模型名以 ollama list 为准

设置页的快捷填充只填地址与模型,不动 Key。

测试连接

POST /api/ai/test 发一个最小请求:system 你是连通性测试助手。,user 只回复 pong 四个字母。, max_tokens 为 8。请求体可以只带表单草稿(baseUrl / model / apiKey),不必先保存。

始终返回 200,用 ok 表达结果:

{ "ok": true, "latencyMs": 1234, "model": "deepseek-chat",
  "endpoint": "https://api.deepseek.com/v1/chat/completions",
  "message": "连接正常,模型已返回响应", "sample": "pong" }

失败时 ok=false、message 是中文可读原因、endpoint 仍返回实际调用的地址、sample 为空串。 未配置地址或模型时直接返回 ok=false 并说明,不发请求。

为什么不是 4xx/5xx:这是一次探测结果,不是服务端处理失败。用状态码表达”连不上对方的模型服务”, 会让调用方和日志都把它当成自己的故障。

使用

三层可选项

层次 怎么控制 缺省行为
单次分析 分析弹窗里的「AI 审核」勾选 取「默认在每次分析时生成」
默认值 设置页的「默认在每次分析时生成」 已配置时默认勾选
历史报告 分析页 AI 卡片上的按钮 不自动补生成

POST /api/analyses 的 aiReview 字段缺失时按「默认值 && 就绪」决定;显式 false 表示这次不生成。 未就绪时勾了也不会让分析失败——跳过并记一条日志。AI 是可选项,不该有能力拖垮必选项。

AI 段从 96% 起,按阶段把百分比推到 99%,detail 行依次是:

阶段 detail
摘要 AI 评审:生成评审要点
逐条意见开始 AI 评审:逐条评审意见
每个评审分块 AI 评审:分块 i/n
每次评论重定位 AI 评审:重定位意见 i/n
反思 AI 评审:反思意见
收尾 AI 评审:整理意见

百分比由「那一段工作自己」的完成比例换算:96 + round(3 × fraction),夹在 96..99。AI 段最多有十几次串行调用, 过去整段只写一次 96%,与卡死无法区分。100% 仍然由 finishAnalysis 独占写入——报告落库之前不宣布完成。

「逐条评审意见」「分块」「重定位」「反思」这四行只在「生成逐条评审意见」打开时出现(反思还要求确有评论); 关闭时摘要之后直接进入「整理意见」。

分析页的四种状态

状态 界面 可用操作
已生成 待验证说明(report.ai.disclaimer)+ 正文 + 逐条意见(如有)+ 脚注(模型 / 生成时间 / 送审条数) 重新生成
生成失败 红色提示 + 失败原因,并注明静态分析结论不受影响 重试
有报告但没生成,AI 就绪 一句说明:本次只包含静态分析事实 生成 AI 评审
有报告但没生成,AI 未就绪 整张卡片隐藏 无

没有报告时同样整张卡片隐藏,也不发任何请求。

最后一行是刻意的:一个从未启用的功能不该在每份报告上留一个空位。卡片只在「有内容要展示」或 「能立刻生成」时出现——这与「默认关闭时界面与改动前完全一致」是同一条约束。

补生成历史报告

curl -X POST http://127.0.0.1:9996/api/analyses/42/ai-review
{ "generated": true, "model": "deepseek-chat", "text": "……",
  "disclaimer": "AI 解释仅基于下列静态分析事实生成,属于待验证的分析结论,不是已验证的事实。",
  "generatedAt": "2026-10-27T21:40:00Z", "maxImpacts": 12,
  "comments": [
    { "id": "c1", "severity": "MAJOR", "category": "CORRECTNESS", "title": "……",
      "detail": "……", "suggestion": "……", "file": "src/main/java/…/OrderService.java",
      "startLine": 42, "endLine": 44, "side": "NEW", "anchor": "HUNK",
      "existingCode": "if (order == null) {", "evidence": ["C1", "I2"], "confidence": 0.82 }
  ],
  "reviewStats": { "filesReviewed": 3, "filesSkipped": 1, "commentsGenerated": 4,
    "commentsKept": 1, "commentsDroppedUnlocated": 1, "commentsDroppedFiltered": 2,
    "commentsDroppedReflection": 0, "commentsMerged": 0, "relocationCalls": 1,
    "reflectionRan": true, "estimatedTokens": 5120, "budgetSeconds": 180,
    "budgetExpired": false, "notes": [] } }
情况 响应
未就绪 400 {"error":"AI 审核未启用或未配置完成:<原因>"}
分析不存在 404
存在但没有可用报告(未完成 / 失败) 409 {"error":"该分析还没有可用的报告"}
模型调用失败 200 + {"generated": false, "error": "HTTP 401: ..."}

成功后会写回 report_json 并同步内存里的报告缓存——不同步的话,刷新页面看到的还是旧内容。

为什么模型调用失败是 200:请求本身被正确处理了,只是解释没生成。用 5xx 会让调用方以为服务端坏了, 而实际情况是”上游模型拒绝了这次调用”,处理方式完全不同(改 Key,而不是重启服务)。

命令行

java -jar code-association.jar analyze --location . --peer /path/to/other-service \
     --ai --out report.json --fail-on HIGH

JSON 报告写入 ai 字段,Markdown 报告多一个独立小节:

### AI 评审说明(待验证)

> AI 生成内容,仅基于下列静态分析事实,不是已验证的结论。

<正文>

<sub>模型 deepseek-chat · 生成于 …</sub>

有逐条意见时再多一节(没有评论时这一节不出现):

### AI 逐条评审意见(待验证)

> 行号由变更片段与文件内容匹配得出,不是模型写的;无法定位到具体行的评论已丢弃。

1. 🟠 **MAJOR** · 空指针判断顺序反了
   - 位置:`src/main/java/…/OrderService.java:42-44`(新增行 · 定位 变更块内匹配 · 证据 C1 · 置信度 82%)
   - 正文:……
   - 建议:……
   - 代码:`if (order == null) {`

统计:审阅文件 3 · 跳过文件 1 · 保留评论 1 · 未定位丢弃 1 · 过滤 2 · 重定位调用 1 · 反思已执行

未配置时打印一行提示,退出码不变。让一个可选项的不可用导致流水线变红, 等于把门禁的判定权交给外部服务。

时间预算

整段 AI(摘要 + 最多十几次评论调用)共享一个墙钟预算 code-association.ai.total-budget-seconds。 它只能写在 application.yml:这是部署形态的参数(什么端点、有多慢),不是每次运行的实验, 所以设置页与 {data-dir}/ai-settings.json 都不带它——快照若带上,第一次保存就会悄悄覆盖部署值。

code-association:
  ai:
    timeout-seconds: 60          # 单次请求,5..300
    total-budget-seconds: 180    # 整段 AI;不写则按下面的规则推导

不写时按 timeout-seconds × 3 推导,并夹在 90..900 秒之间:默认 timeout-seconds: 60 得到 180 秒。 推导而不是必填,是为了让已经在设置页调大的单次超时同步抬高整段预算;上下限则保证慢的本地模型仍有 几次调用的余地,而死端点不会把一次分析占住一刻钟。

预算是硬上限。每次评论调用前检查剩余时间,单次请求的超时也收缩到剩余预算之内;预算耗尽后不再发起剩余调用, 已生成的部分照常保留。预算截断这一段有两种形态:

出现任一形态时,reviewError 由「第一条具体错误 + ; + AI 评审超出时间预算(N 秒),已保留已生成的部分」合成; 第一条错误超过 200 字时先截断,保证预算那句在整行 300 字上限内永远保留。没有具体错误但被截断时只有预算句; 预算没有参与截断时原样保留第一条错误(如 HTTP 401: …)。

端到端实测的 reviewError:

第 1 个分块评审失败:模型调用超时(5 秒),请检查接口地址、网络或模型响应速度;AI 评审超出时间预算(8 秒),已保留已生成的部分

形态 (a)(真的跳过了调用)budgetExpired=true;形态 (b)(只有 2 个分块、预算被第 2 个分块吃完) budgetExpired=false,但 reviewError 是同样的合成形态。

逐段明细在 reviewStats.notes:分块被截断记 AI 评审超出时间预算,剩余 N 个分块未评审, 重定位被截断记 AI 评审超出时间预算,剩余 N 条意见未重定位,反思被跳过记 AI 评审超出时间预算,跳过反思,保留全部评论。摘要本身失败时 report.ai.error 是 AI 评审超出时间预算(N 秒),已保留已生成的部分。

budgetExpired 只在真的跳过了模型调用时为 true;预算恰好用尽但所有调用都跑完是 false。 这个字段用于区分是否还有未送审的分块,不等于评论是否完整——形态 (b) 里评论同样不全,字段却是 false。 已生成的评论仍会走定位与过滤,只有没来得及重定位的会缺失;反思被跳过时不会删除任何评论。 单次调用撞上传输层看门狗时,错误里是 模型调用超时(N 秒,AI 评审时间预算已用尽),请检查接口地址、网络或模型响应速度 (预算未耗尽时没有「AI 评审时间预算已用尽」这一句)。

为什么需要这层预算,而不是只靠单次 timeout-seconds:

预算耗尽不是失败:静态分析结论早已算完并写入报告,AI 只是可选的最后一段。

逐条评审意见(行级定位)

除了整段说明,AI 审核还能给出逐条意见:每条带严重级、类别、标题、正文、建议与一段代码引用, 并锚定到 文件:行。硬约束只有一条——行号不由模型写。

为什么不让模型写行号

模型看到的是 diff 文本,它自己数行会错(+ 前缀、上下文行、被截断的 hunk 都会让它偏移), 而一条”看起来对但位置错了”的评论比没有评论更糟:评审者点过去发现代码对不上,就再也不会信任这一栏。

所以协议里没有行号字段。模型必须逐字引用 existing_code——它评论的那段代码;行号由本机确定性代码 拿这段引用去 diff 与文件里找。引用再准也找不到时,这条意见被丢弃(计入 commentsDroppedUnlocated), 而不是猜一个接近的位置。

三层定位

层 在哪找 何时使用 anchor
① hunk 文本匹配 本次 diff 的新侧;删除行回退旧侧 引用的是变更行或 hunk 上下文 HUNK
② 整文件扫描 本机读取的新版本文件 引用的是文件里其它未变更部分;需要本次分析提供的文件快照 FILE
③ 模型重定位 再问一次模型”这段代码在文件里哪里” 前两层都没匹配上,最多 6 次调用 RELOCATED
失败 — 三层都失败,丢弃并计数 NONE(不会进报告)

匹配细节按”宁可丢、不可错”设计:

重定位层是为”模型引用了文件里没变的那几行”准备的:这类评论如果直接丢弃,会把真实问题一起扔掉; 最多 6 次是成本上限——每次重定位都是一次完整模型调用。

误报过滤:确定性优先,模型只删事实错误

两层,顺序固定:

  1. 确定性过滤(不花钱、可复现):评论的文件必须是本次送审文件;引用的证据编号必须真实存在; 行号范围自洽(startLine <= endLine);严重级 / 类别在白名单内;标题与正文非空; 文件 + 行重叠 + 标题相似的重复项合并,保留更严重的一条;单文件与总数按上限截断。 被丢弃的评论在内部记录 filterNote,但不写进报告的 comments。
  2. 反思模型(可关):把保留下来的评论连同 diff 再问一次模型,只问一个问题——哪条评论与代码事实矛盾。 可删的理由只有三类:引用的代码不存在、片段反驳了论断、引用的符号不存在。 “价值低”“无法验证”“不够严重”都不是删除理由:把过滤器变成第二个评审者,只会把误报换成漏报。

反思调用失败或返回的 JSON 解析不出来时,保留全部评论。宁可在报告里多一条待验证意见, 也不能因为解析失败把正确的意见删掉。

token 预算

提示词是成本,也是上下文上限,所以每一步都有硬上限(常量在 AiReviewPipeline / TokenEstimator):

环节 上限 为什么
送审文件 20 个 一份报告能读的文件数与评审价值不成正比
单文件 diff 约 4000 tokens 单个超大文件不把整份预算吃掉;超预算跳过并计数
单 chunk 的 diff 约 8000 tokens 按 token 估算把 diff 切成多个 chunk,逐块调用;事实目录与规则另算
输出 每 chunk 最多 10 条评论 一次回答太长时模型更容易编造
重定位 6 次调用 每层重定位都是一次完整模型调用
反思输入 约 12000 tokens 超预算时只复核前若干条(按严重级),其余保留而不是被静默丢弃
评论总数 max-comments(默认 12,上限 50) 前几条评论价值最高,长列表反而更难处理

跳过的文件、丢弃的评论都进 reviewStats,所以”评论很少”可以解释,而不是只能猜。

报告结构

report.ai.comments 每条:

字段 含义
id 本次生成内的稳定编号,用于和反思结果对应
severity BLOCKER / CRITICAL / MAJOR / MINOR / INFO
category CORRECTNESS / SECURITY / PERFORMANCE / RELIABILITY / MAINTAINABILITY / IMPACT
title / detail / suggestion 标题、正文、建议
file / startLine / endLine / side 位置;side=OLD 表示删除行
anchor 定位层:HUNK / FILE / RELOCATED
existingCode 模型逐字引用的代码,定位的输入
evidence 依据的事实编号(C1 / I2 / B3 等)
confidence 由定位层决定:HUNK 0.95 / FILE 0.85 / RELOCATED 0.9(不会采用模型自评分)

report.ai.reviewStats 是这一轮的计数:filesReviewed、filesSkipped、commentsGenerated、 commentsKept、commentsDroppedUnlocated、commentsDroppedFiltered、commentsDroppedReflection、 commentsMerged、relocationCalls、reflectionRan、estimatedTokens、budgetSeconds(本次预算的秒数, 0 表示没有上限)、budgetExpired(真的跳过一次调用时为 true,即还有分块未送审;它不表示评论是否完整——预算缩短最后一次调用的超时并导致其失败时,评论同样不全而字段是 false)、notes(失败与预算相关的明细)。 逐条评审整体失败时另有可选的 reviewError;摘要照常保留——评论是加法,不能把已有的说明弄丢。

界面与导出

隐私边界

一句话概括:只发送变更片段与分析结论,不发送源码全文,也不发送仓库信息。

发送的内容(都是分析结论或变更片段,不是完整源码):

内容 字段 上限
变更清单 变更类型、模块、符号全名与签名、一句话摘要 15 条
影响条目 风险等级与分数、目标模块与符号、影响类型、置信度、深度、原因、逐跳路径与每跳用法 max-impacts(默认 12)
盲区 文件:行、无法解析的表达式、原因 8 条
变更片段(仅「逐条评审意见」开启) diff 中的变更行及其上下文、文件路径与行号 20 文件 / 单文件约 4000 tokens / 单 chunk 约 8000 tokens

两段调用分开授权、分开计数:摘要只发结论;逐条意见必须让模型看到变更片段才能评论。 关掉「生成逐条评审意见」就回到”只发摘要”,已接受旧隐私承诺的安装不需要改变行为。

不发送:

不发送的东西 原因
源码全文(未变更部分)与仓库其它文件 提示词只需要结论与变更片段;发整个文件等于把仓库交给第三方
仓库地址与远端 URL 与解释无关,却会泄露内部项目位置
Git 凭据 与模型服务无关
API Key(回显 / 日志 / 错误信息) 只出现在 Authorization 请求头里

第 ② 层”整文件扫描”容易引起误会,单独说明:它由本机的定位代码读取新版本文件,目的是把模型引用的 某段代码换算成行号。文件内容不进入提示词,只有匹配到的位置留在报告里;补生成历史报告时这一层直接跳过。

上限存在的理由很具体:报告规模随影响条数增长,不设上限时提示词会跟着涨, 最终既超出模型上下文,也让一次分析的费用不可预测。

仍然需要留意的一点:类名、方法名与模块名本身就在发送内容里。它们不是源码, 但足以暴露系统结构。对代码结构敏感的场景应先确认合规再启用——这也是它默认关闭的原因。

排错

状态显示「配置不完整」

看状态行右侧那句话,它会指出缺什么(接口地址 / 模型)。GET /api/ai 的 reason 是同一句话。

测试连接失败

测试结果区会显示 endpoint 与 message。先确认 endpoint 是不是预期的地址—— 拼错地址时最容易发生的不是报错,而是打到一个能通但不对的服务上。其余情况见 故障排查 的「AI 审核失败」一节。

改了设置没生效

GET /api/ai 的 configuredBy 说明当前生效值来自哪里:

configuredBy 含义
file 存在 ai-settings.json 且加载成功,它覆盖了 application.yml
application.yml 没有设置文件,用的是 yml 的配置

如果显示 file 却和 yml 不一致,这是预期行为——快照覆盖基线。想回到 yml,删掉文件重启。

分析页没有逐条意见

先确认设置页的「生成逐条评审意见」是开着的,再看卡片底部那行统计:未定位丢弃、过滤、跳过 分别说明 评论去哪了。三种常见情况:

现象 含义
显示「本次变更未发现需要评论的问题」 流水线跑完了,但没有评论通过定位与过滤;不是功能坏了
看不到评论区 老报告的 ai 里没有 comments 字段(生成时该开关是关的);重新生成一次即可
只有一行「逐条评审部分失败」 某几次模型调用失败,摘要照常;看 notes 与日志 event=ai.review 的 error

分析页看不到 AI 卡片

先看 GET /api/health 的 aiReady。为 false 且这份报告里也没有 AI 内容时,卡片整张隐藏, 这是预期行为——未启用的功能不在每份报告上留空位。

配置保存成功是立即生效的,不需要重启。保存后仍看不到,按顺序检查:

  1. GET /api/ai 的 ready 是否为 true(为 false 时看 reason);
  2. 接口地址是否以 http:// 或 https:// 开头(否则保存会被 400 拒绝,界面上有 toast);
  3. 模型名是否为空。

报告里已有历史 AI 内容时卡片照常显示,只是不再提供生成按钮。

相关接口

方法 路径 作用
GET /api/ai 当前配置(Key 为掩码)+ ready / reason / configuredBy / settingsFile
PUT /api/ai 保存配置,立即生效;校验失败 400 + 中文原因
POST /api/ai/test 测试连接,始终 200,用 ok 表达结果
POST /api/analyses/{id}/ai-review 补生成 / 重新生成,返回 AI 对象本身

PUT 的校验规则(失败一律 400):

情况 提示
已启用但地址为空 已启用 AI 审核,但未填写接口地址
已启用但模型为空 已启用 AI 审核,但未填写模型
maxImpacts 不在 1..50 每条分析最多影响条数需在 1..50 之间
maxComments 不在 1..50 每条分析最多评论条数需在 1..50 之间
timeoutSeconds 不在 5..300 请求超时需在 5..300 秒之间
地址协议不对 接口地址需以 http:// 或 https:// 开头

日志只记录 enabled / baseUrl / model / maxImpacts / comments / maxComments, 不打印 API Key;逐条评审另记一行 event=ai.review(评论数、审阅文件数、丢弃与反思计数、耗时、错误)。