ci: 接入 vertai-tech/ai-review-engine 对抗式 AI PR 审查

- .gitea/workflows/ai-review.yml 原样拷贝引擎模板(含 pr_number 经 env 传参的
  注入防护),引擎钉 v1 移动标签,通过 REVIEW_ENGINE_TOKEN 只读 PAT 拉取
- .gitea/ai-review.md 按本仓库技术栈写审查侧重:金额精度(Decimal/NUMERIC)、
  缺失值语义与对账容差、文件路径符号链接安全、endpoint 角色与 worker 撤权核验、
  事务并发与 revision、Excel 公式注入与长度上限、前端异步状态、测试真实性
- 仓库级 Actions 配置(Gitea 设置,不在本提交内):REVIEW_ENGINE_TOKEN secret、
  REVIEW_BASE_URL/MODEL/PUBLIC_URL/PRICES、FIX_MODEL/FIX_MODEL2/FIX_PLAN_MODEL/
  FIX_PLAN_THINKING,以及新仓库推荐起点 REVIEW_FIX_SUGGEST_ONLY=1(只给改法)
This commit is contained in:
Jayway Liu
2026-09-26 15:22:08 +08:00
parent 0739bcc4fb
commit a3d91ad17f
2 changed files with 229 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# fa-agent (TraceIQ 财务分析) review profile
## Stack and verification
- Django 5.2 后端(PostgreSQL 生产 / SQLite 仅开发),金额一律 `NUMERIC(44,24)` + Python `Decimal`(精度 60);JSON 中金额为字符串。
- React 19 + TypeScript + Vite 前端(AG Grid、ECharts),`npm run build` 含 `tsc --noEmit`。
- openpyxl 流式生成公式工作簿(SUMIFS/COUNTIFS),公式正确性靠 LibreOffice 无头重算验证。
- pytest(`--cov-fail-under=85`)+ Playwright E2E;CI 在 PostgreSQL 17 服务容器上跑全量。
- 验证命令:`uv run pytest`、`uv run ruff check server finance tests scripts`、`npm run typecheck`、`python manage.py makemigrations --check`。
## Review priorities
1. **金额精度**:财务计算、持久化、Excel 公式生成路径上出现 `float()`、`round()`、Decimal 与 float 混算、或金额以数字(非字符串)进 JSON,必须报。浏览器 `Number` 只允许用于绘图与显示。
2. **缺失值语义**:成本/毛利/人数等依赖字段缺失时必须传播为不可用(服务端 `None`、Excel `#N/A`),不得静默转 0 或跳过;比率分母为零/非正同样不可用。对账容差按指标类型独立(人数严格相等、金额 0.01、比率 0.0001),不得为消除报错统一放宽容差。
3. **文件与路径安全**:清理/下载/回收文件前必须 `lstat` 校验且拒绝符号链接(目录用 `O_NOFOLLOW` 打开),只删除本次任务在受控目录内创建的文件;清理失败要留日志与可追踪状态,不得静默吞掉 `OSError`。
4. **越权与生命周期**:每个 endpoint 必须声明 roles 白名单;worker 执行阶段要重新核验角色、`is_active`、组织与科室范围(不能只信创建时的检查);卡死任务要有超时恢复,且恢复不得与活跃 worker 抢任务。
5. **事务与并发**:配额/唯一性检查必须在事务内并锁定稳定的所属行(如 Membership),审计与业务变更同事务提交;乐观并发用 revision + 409,不得静默后写覆盖。
6. **Excel 公式注入与上限**:不可信文本写入单元格必须强制文本类型;CSV/公式中 `= + - @` 开头加 `'` 前缀;SUMIFS/COUNTIFS criteria 必须转义 `~ * ? "` 并检查 255 字符上限、公式 8192 字符上限。
7. **前端异步状态**:useEffect 依赖未变时不得清空已加载数据(同状态重复点击不能停在“加载中”);并发请求要有过期响应丢弃(active flag/AbortController);失败路径要有可见错误与重试。
8. **测试真实性**:行为修复必须带“修复前失败、修复后通过”的回归测试;环境门禁(LibreOffice、PostgreSQL 并发、真实 AI)必须显式 skip 并注明原因,不得用 mock 通过冒充真实验收。
## Avoid reporting
- 纯格式与风格问题(ruff / prettier 已在 CI 把关)。
- 改动行为已被等价集成测试或 E2E 覆盖时,仅因缺少同名单测而报缺测试。
- 没有具体并发路径的假想竞态(SQLite 单写者语义下的理论交错不算,除非改动显式依赖 PostgreSQL 行为)。
- 对 `docs/` 报告类 HTML/Markdown 的措辞与结构建议——它们记录的是验收状态,不是产品代码。
+202
View File
@@ -0,0 +1,202 @@
name: AI Review
# 对抗式 PR 审查:先一遍找出候选缺陷,再对每一条派一个反驳者去证伪它,只有
# 活下来的才会发到 PR 上。这个不对称是刻意的——AI 审查员死于误报远早于死于漏
# 报,所以反驳阶段默认站在"这是误报"一边,"什么都没发现"是可接受的结果。
#
# 引擎不在本仓库,在 vertai-tech/ai-review-engine。本文件只负责把三样东西摆到
# 位再调它:被审查的代码、本仓库的审查侧重、引擎本身。
#
# 接入一个仓库需要(各一次):
# 1. 引擎仓库的 templates/ai-review.yml -> 本仓库 .gitea/workflows/ai-review.yml
# 2. 引擎仓库的 templates/ai-review.md -> 本仓库 .gitea/ai-review.md,按本仓库
# 的技术栈改写
# 3. 下面这些组织级机密 / 变量(组织设置 → Actions)配好之后,新仓库就不用再
# 单独配任何东西:
# secret REVIEW_API_KEY 模型网关的 key
# secret REVIEW_ENGINE_TOKEN 只读 PAT,用于拉私有的引擎仓库
# variable REVIEW_BASE_URL 任意 OpenAI 格式的根地址
# variable REVIEW_MODEL 支持 function calling 的模型
#
# 自动修复默认是关的。要开,在**本仓库**的 设置 → Actions → 变量 里加一个(这两条
# 都不要配到组织级,那样等于给所有仓库都开了),并在组织级配一次
# REVIEW_FIX_MODEL:
# REVIEW_AUTOFIX=1 改代码、提交、推回 PR 分支
# REVIEW_FIX_SUGGEST_ONLY=1 只把改法写进评论,一个字节都不落盘
# 两个都配的时候,不动仓库的那个赢。fork 来的 PR 推不回去,apply 会自动降级成
# 只给改法。
#
# 默认只修一轮:修完推一个 commit,那次推送起的下一轮只审不改。要"一直修到没
# 意见",把仓库变量 REVIEW_FIX_ROUNDS 调大——它数的是这个分支已经被自动修过几
# 轮,轮次写在提交消息里。
#
# 改法本身是两步定出来的:一个模型决定这条该不该由机器改、以及改完长什么样,另一
# 个模型把它落成逐字命中的替换。想让这两步用不同的模型,就再配一个组织级的
# REVIEW_FIX_PLAN_MODEL——判断那步值得用贵的,抄写那步不值得。不配也照样分两步,
# 只是两步都用 REVIEW_FIX_MODEL。
#
# 成本由模型决定而不是由这个设计决定:同一个 PR 在便宜模型上是几分钱,在旗舰
# 模型上是几美元。换模型前先看一眼那家的每 token 价格。
# 换了模型记得同步组织变量 REVIEW_PRICES,缺单价的那轮评论不报金额。
#
# 只发 COMMENT 类型的 review,从不 approve,也从不 request changes。所有发现集中
# 在一条评论里按 1、2、3 编号,不再逐条钉成行内评论。
on:
pull_request:
# 没有 ready_for_review:Gitea Actions 不实现这个活动类型(它支持的是
# opened / edited / closed / reopened / assigned / unassigned /
# synchronize / labeled / unlabeled / milestoned / demilestoned /
# review_requested / review_request_removed)。写上去只会让人以为有这个
# 覆盖,实际没有。
#
# 留下的缺口:草稿 PR 会被下面的 draft == false 挡掉,所以"取消草稿但不再
# 推代码"不会触发任何东西。再推一次,或者用下面的 workflow_dispatch 手动
# 指定 PR 号——那条路就是为这个场景留的。
#
# 用 edited 能覆盖取消草稿,但它同时会在每次改标题、改描述时触发,而每一
# 次都是一整轮 find + refute 的钱。为一个"再推一次就解决"的场景不值。
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to review"
required: true
type: string
# 连着推两次的 PR 只该被审一次——取消在飞的那轮,而不是花钱买一份关于旧代码
# 的报告。
concurrency:
group: ai-review-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# 草稿按定义就是没写完的东西;手动 dispatch 是明确的请求,所以绕过这条。
# vars 那两个条件意味着本文件在网关配好之前合入也是安全的——job 直接跳过。
if: >-
(github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false)
&& vars.REVIEW_BASE_URL != ''
&& vars.REVIEW_MODEL != ''
runs-on: ubuntu-latest
# 不设的话是 Gitea 默认的 6 小时,而下面的 continue-on-error 帮不上忙——它
# 兜的是失败,不是挂起——于是一次卡在不返回的 API 调用上的审查会一直占着
# runner,PR 的 check 一下午都是 pending。fantap-server 真踩过这个。
#
# 60 分钟是照实测定的:一次阶段一实测 710 秒、29k 输出 token(其中 28.7k
# 是推理),阶段二还要在这之上再加最多八条反驳,开了自动修复的话阶段三再加
# 最多八条的规划加执行。阶段三从每条一次调用变成每条两次(执行没对上文件还
# 会重试)之后,原来的 45 分钟只在八条全中时才不够,但不够的表现是卡在超时
# 上而不是干净报错,所以往上留一档。上面 concurrency 的 cancel-in-progress
# 负责拦住堆积。
timeout-minutes: 60
permissions:
# write 只有 REVIEW_AUTOFIX 那个模式才用得上——它要把改动推回 PR 分支。
# 没开自动修复、或者只开了 REVIEW_FIX_SUGGEST_ONLY 的仓库,把这一行改回
# read 就是了:只给改法的模式一个字节都不落盘,不需要写权限。留着 write
# 也没事,引擎只在 apply 模式下才提交任何东西。
contents: write
pull-requests: write
steps:
# 一、被审查的代码。反驳阶段要读 PR head 上的整份文件,所以检出 head,
# 不是 merge commit。
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', inputs.pr_number) }}
# 二、本仓库的审查侧重(.gitea/ai-review.md),取默认分支的那一份,不取
# PR 分支的——否则一个 PR 可以先改掉审查重点,再让改过的重点来审自己。
# || 'main' 是给 Gitea 兜底:它的 workflow_dispatch 载荷不总是带
# github.event.repository。
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch || 'main' }}
sparse-checkout: .gitea
path: .review-profile
# 三、引擎。跟着 v1 这个移动标签走:引擎修了 bug 移一次标签,所有接入的
# 仓库同时生效。某个仓库被新版引擎坑到时,把这里单独钉到具体 commit 就能
# 脱身,不用回滚全局。
#
# 引擎仓库是私有的,Gitea 的 GITHUB_TOKEN 只对本仓库有效,拉不动它,所以
# 要一个组织级的只读 PAT。continue-on-error 在这里是必要的:PAT 没配或者
# 过期时,这一步失败不该把别人的 PR 弄红——下面那步会说明情况然后干净退出。
- uses: actions/checkout@v4
continue-on-error: true
with:
repository: vertai-tech/ai-review-engine
ref: v1
path: .review-engine
token: ${{ secrets.REVIEW_ENGINE_TOKEN }}
- name: Adversarial review
# 设计上就是建议性的:引擎自己的错误已经会干净退出,这里兜的是剩下的
# (进程被杀、脚本没看见的 curl 失败)。审查机器人不该让人的 PR 变红。
continue-on-error: true
# 比 job 的预算小一档,这样超时是记在这一步上,而不是整个 job 被砍——
# 后者在界面上看不出是谁超的时。引擎内部 curl 的 max-time 比这再小一档
# (1200s),所以正常顺序是那一层带着响应体和日志先报错,而不是这一层
# 无声地杀掉进程。
timeout-minutes: 55
env:
REVIEW_API_KEY: ${{ secrets.REVIEW_API_KEY }}
REVIEW_BASE_URL: ${{ vars.REVIEW_BASE_URL }}
REVIEW_MODEL: ${{ vars.REVIEW_MODEL }}
# 可选,组织级变量。网关不在响应里报金额时(sub2api 就不报),评论按这张
# 单价表算金额;不配就只报 token。格式见引擎 README「每轮花了多少」。
REVIEW_PRICES: ${{ vars.REVIEW_PRICES }}
# 上游报过载 / 限流时每次重试前等几秒。不配就是 10,30,60,写 0 关掉。
REVIEW_BUSY_RETRY_WAITS: ${{ vars.REVIEW_BUSY_RETRY_WAITS }}
# 评论里行号链接用的站点地址,如 https://git.sciark.cn。runner 在容器里访问
# Gitea 时,API 返回的 html_url 和 GITHUB_SERVER_URL 都可能是容器内地址,
# 不配这个链接就打不开。组织级配一次。
REVIEW_PUBLIC_URL: ${{ vars.REVIEW_PUBLIC_URL }}
# PR 号和仓库名经 env 进脚本,不直接拼进 run:手动 dispatch 的 pr_number
# 是任意字符串,表达式在 shell 解析之前就被展开,带引号就能注入命令。
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
REVIEW_REPO: ${{ github.repository }}
# 引擎的 Gitea REST helper 读这个。GITHUB_API_URL 由 act_runner 导出,
# 已经指向 <instance>/api/v1。
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 自动修复。REVIEW_AUTOFIX 没配或不是 1 的话,下面几个都没有意义。
REVIEW_AUTOFIX: ${{ vars.REVIEW_AUTOFIX }}
REVIEW_FIX_SUGGEST_ONLY: ${{ vars.REVIEW_FIX_SUGGEST_ONLY }}
REVIEW_FIX_MODEL: ${{ vars.REVIEW_FIX_MODEL }}
# 一个分支最多自动修复几轮。不配就是 1:修一次,下一轮只审,跟从前
# 一样。配大了才是"一直修到没意见",代价是往作者分支上推更多 commit、
# 一个 PR 烧更多轮的钱。硬上限在引擎里。
REVIEW_FIX_ROUNDS: ${{ vars.REVIEW_FIX_ROUNDS }}
# 规划用哪个模型。这不是开关——不配也照样先规划再执行,只是两步同一个
# 模型。配了才是"贵的判断、便宜的抄写"那种分工。
REVIEW_FIX_PLAN_MODEL: ${{ vars.REVIEW_FIX_PLAN_MODEL }}
# 只有当写修复的模型不在审查那个网关上时,才需要这两个。
REVIEW_FIX_BASE_URL: ${{ vars.REVIEW_FIX_BASE_URL }}
REVIEW_FIX_API_KEY: ${{ secrets.REVIEW_FIX_API_KEY }}
# 同上,只有当规划的模型连修复那个网关都不在时才需要。
REVIEW_FIX_PLAN_BASE_URL: ${{ vars.REVIEW_FIX_PLAN_BASE_URL }}
REVIEW_FIX_PLAN_API_KEY: ${{ secrets.REVIEW_FIX_PLAN_API_KEY }}
# 规划的思考强度。不配就是模型默认;写 high 之类的词按 reasoning_effort
# 发,写 JSON 对象就原样并进请求。各家写法见引擎 README。
REVIEW_FIX_PLAN_THINKING: ${{ vars.REVIEW_FIX_PLAN_THINKING }}
# 对照用的第二个执行模型。只在直接改的模式下跑,改动不落盘,只和
# REVIEW_FIX_MODEL 的改动一起贴进评论,方便比较哪个模型抄得好。
REVIEW_FIX_MODEL2: ${{ vars.REVIEW_FIX_MODEL2 }}
REVIEW_FIX_MODEL2_BASE_URL: ${{ vars.REVIEW_FIX_MODEL2_BASE_URL }}
REVIEW_FIX_MODEL2_API_KEY: ${{ secrets.REVIEW_FIX_MODEL2_API_KEY }}
# 只有当上面那个自动 token 推不动分支时,才需要这个写权限 PAT。
REVIEW_FIX_TOKEN: ${{ secrets.REVIEW_FIX_TOKEN }}
run: |
ENGINE=.review-engine/scripts/ai_review.py
if [ ! -f "$ENGINE" ]; then
# 拉引擎那步失败了。说清楚原因,否则这里的沉默会被读成"没发现问题"。
echo "::warning::引擎没拉下来(多半是组织级 secret REVIEW_ENGINE_TOKEN 没配或已过期),本轮未审查。"
exit 0
fi
case "$PR_NUMBER" in
''|*[!0-9]*)
echo "::warning::PR 号必须是数字,收到的是:$PR_NUMBER。本轮未审查。"
exit 0 ;;
esac
python3 "$ENGINE" --repo "$REVIEW_REPO" --pr "$PR_NUMBER"