从提交记录写周报,还要逐句核验证据
Receipts 用本地模型读取一周提交记录生成客户周报,并检查每句话是否得到所引用提交的支持。作者发现,模型的问题常是夸大已有改动的成果,而非凭空捏造工作。
完整中文译文
Hacktoberfest 开源 AI 挑战赛第三周:慢而稳 🦥
本文是 Hacktoberfest 开源 AI 挑战赛第三周「慢而稳」的参赛作品。
每到周五,我都得给客户写一段话,交代这一周做了什么。信息全在 git log 里。可到了周五下午 6 点,我实在提不起劲,从四十条 commit message 和一份连自己都忘了写过的 diff 里,把这一周重新拼出来。
Receipts 会用本地模型读取一周的 commit,写出工作汇报。然后,它会做我真正关心的那件事:逐句对照引用的 commit,检查每句话,并告诉我哪些句子得不到对应 commit 的支持。
我发现,模型几乎不会凭空捏造工作。它会把工作拔高。Pin requests<3 会变成「通过重构,将 requests 固定到版本 2.31.0」——但这条 commit 恰恰移除了 2.31.0。Run black 会变成「实现了导出函数」——black 只是重新格式化了这个函数,真正编写它的是另一条 commit。
单独看,这些句子似乎都能找到辩护的理由。可放在一起,它们就是谎言,而没人会去核查工作汇报。这正是这个项目切入的缝隙。
打开页面,首先看到的是客户会收到的那份汇报:六条语气笃定的要点,没有引用,也没有任何可点击的内容。拨动开关,每句话下面都会展开它所依据的 commit:SHA、message、文件、diff,以及检查结论。
六份凭据里,有三份无法支持上面的句子。无论是否展开凭据,这份汇报读起来都很像样。整个项目要表达的东西,就在这一次交互里。
这个 demo 把一次已保存的运行结果嵌进了静态页面,因为模型有 3.4GB,而 GitHub Pages 没有 CPU 可供运行。页面回放的是一次真实运行,未经编辑:data/demo.json 就是模型的原始输出,检查结论也来自真正的检查器,并非模拟结果。要实际运行模型,可以使用 CLI:
PYTHONPATH=. .venv/bin/python scripts/report.py ~/code/some-client --limit 40
把它指向我上一个项目的 repo——真实代码、真实 commit,不是测试用的 fixture:
parchi: 9 commits, 2026-10-17 -> 2026-10-17
ok The counselling text has been translated into a native Hindi voice using
ElevenLabs, resulting in a 39.0-second audio file with 610 KB and a
44.1 kHz sample rate
FAIL The LICENSE file has been added, clarifying that this project is licensed
under the MIT License
[715e025] does not support — Install TabPFN for real, and pin NumPy under 2
needed [36dd958] Add an MIT license, and say plainly what this is not
FAIL The `README.md` file has been updated to include a self-check script
(`scripts/selfcheck.py`) that verifies the functionality of the system
[ced916a] does not support — Stop the counselling saying the strength twice
2 of 6 receipts do not support their sentence.
第二个失败案例,是整个项目里我最喜欢的一个。ced916a 确实是创建 scripts/selfcheck.py 的那条 commit。但 README 中介绍这个脚本的那一行,是在前一条 commit 744075a 里加上的。模型找对了文件,却找错了 commit——这恰恰是人类扫一眼就会放过的错误,因为文件确实存在,那句话本身也是真的。
app/trace.py——三条确定性规则。不用模型,不调用 API,不联网。
app/receipts.py——如果存在能支持某个错误句子的 commit,就把它找出来。
scripts/eval.py——下文所有数字背后的评测程序。
scripts/report.py——上面用到的 CLI。
scripts/make_fixture_repo.py——构建包含 12 条 commit 的 demo repo,连同其中的陷阱。
data/eval_base.json、data/eval_primed.json——两种运行结果都已提交进 repo,方便核查文中的说法。
从你确实能抓住的错误开始
我不想做抄袭检测器,也不想做什么「幻觉评分」。我只想抓住一种具体问题:一句话所附的凭据,并不能支持这句话。因此,这道检查关卡只有三条规则,都很朴素,也都是确定性的。
依据核验(Grounding)。 句子中的数字和标识符,必须出现在它引用的 commit 里。这里还包括一个曾让初版实现栽跟头的区别:如果某个版本只出现在被删除的行里,那它是被移除了,而不是被引入了。没有这层区分,「将 requests 固定到 2.31.0」就能通过检查,即便对应 commit 实际上把 requests==2.31.0 改成了 requests<3,因为 2.31.0 确实就在 diff 里。一个说法即使与事实完全相反,也能通过字符串搜索。
夸大检查(Inflation)。 把句子开头的动词,与 commit message 开头的动词按级别比较。refactored 和 Pin 相差两个级别。这里并不是说哪个动词比另一个更差,而是说:这句话和它的凭据,描述的工作规模并不一致。
范围检查(Scope)。 一条只改了一个文件的 commit,却被描述成对「整个系统」「整个平台」「整个代码库」做了改动。
整道检查关卡就这些。它是一个分诊工具,不是裁判。它不判断一句话写得好不好,只判断哪些句子需要我亲自读一遍。
陷阱本身就是测试
评测 repo 里有十二条合成 commit,模拟一个小型开票客户项目一周的工作。其中两条改动了几百行,却没有实质意义:Run black、Rename qty to quantity。另一条只改了六行,却很重要:Fix null check in parse_invoice。如果检查器按代码改动量排序,每次都会指向错误的 commit——而这恰好就是模型的做法。
下面是已保存运行结果中的五条真实输出。左边是模型引用的内容,右边是模型说的话。
每条引用都指向真实存在的 commit,因此链接检查器会让这五条全部通过。其中四条属于归因错误,而不是捏造:工作确实做过,只是凭据给错了。第五条把 README commit 说成了功能实现,尤其扎心,因为在 repo 里,文档 commit 最容易被包装成工程成果。
两种 prompt 变体,每种运行三次,每次生成六条要点:总共 36 条。
其中 13 条引用了无法支持其表述的 commit。检查关卡把这 13 条全部标了出来。
另有 4 条也有错,但错在这道关卡看不到的地方。
第二个数字才有意思,而且只有去读那些已经通过检查的句子,才能发现它。一条把 sleep 常量从 0.2 改成 0.25 的 commit,生成了这样的描述:
「通过引入带有指数退避的重试机制,改进了 rates.fetch_rate 内部的错误处理。」
重试机制早在两条 commit 之前就引入了。这条 commit 只是调整了等待时间。动词对得上,没有数字需要核验,也没有提到文件名,所以三条规则全部放行。一个只汇报自身标记结果的检查关卡,发现不了自己漏掉的错误。如实统计,36 条里有 17 条有问题,其中抓到了 13 条。
同一类改动,也在加入提示引导的变体中,生成了整个数据集里最好的一句话:
「通过将 rates.fetch_rate 内部的 sleep 时间调整为 0.25 * (attempt + 1),改进了错误处理。」
同一条 commit,同一个模型,这次却准确写出了那个常量。在 prompt 里加入引导——「按字面描述,不要拔高动词」——并没有稳定降低被标记的比例:7/18 对 6/18,这点差异只是噪声。但它确实让那些通过检查的句子更贴近实际改动。这是一个真实但有限的进步,我不会把它包装得更了不起。
输出帮我发现的三个 bug,测试没发现
这三个问题,都是我读到某条被标记的句子,心想「这句话没问题啊」时发现的。
把文件名当成了动词。 「运行 black,格式化 export.py、invoice.py 和 rates.py 中的代码」被标记了,因为检查器认为它使用了强度为 3 的 export 动词。可那只是文件名。句子正确,标记错误。
从句抬高了动词级别。 「将版本升级到 1.2.1,以反映更新后的重试逻辑」被标记了,因为 retry 的级别高于 bump。但这句话真正声称完成的工作在主句里,后半句只是背景说明。
用 commit message 里最显眼的词打分。 这个问题还藏着一次漏报。「记录重试行为」与「为 fetch_rate 添加重试」被评成了同样的强度,因为 retry 也可以是动词。于是,「实现了重试逻辑」即使引用的是文档 commit,也能顺利通过。commit message 通常以动词开头。两边都应该读第一个动词。
这三个问题都已修复,也都已加入检查器自己的测试集。没有一个来自我预先写好的测试。
本周的主题是偷懒,那就说说,我的周五究竟少做了哪些事。
我不再打开 repo,凭记忆重建这一周。我只需运行一条命令,在一台没有 GPU 的八核笔记本上等大约两分钟,就能得到六条要点。这是显而易见的节省,却只是较小的那一半。
更大的节省在于,我不用通读整份汇报做校对。我只读被标记的句子,以及它们背后的凭据。这次运行,我需要读的是三句话,而不是六句;每句话都已经附好了 diff,其中一句甚至已经告诉我,我本来想引用的是哪条 commit。我省掉的,恰恰是自己最不擅长的工作:拿一句看起来合理的话,去核对一条只记得一半的 commit。
还有一种间接带来的偷懒收益,是我没预料到、现在也不愿放弃的。因为模型在本地运行,不花钱,我可以一遍遍跑评测、读原始输出、改写 prompt,不用惦记账单。上一节里的每个 bug,都是这样发现的。如果每次运行都是按量计费的 API 调用,你就很难得到这样的工作方式。
开放创新为什么重要?
diff 里装着客户的代码。这个工具读取完整 diff,而不只是 commit message——正因如此,凭据才有意义。每周五把客户的私有 repo 发给托管 API,是一个我必须向客户解释的数据处理决定。在已经存放着 repo 的那台笔记本上运行模型,就不需要再做这个决定。数据不会离开机器。
检查器独立于模型,但只有模型可以替换,这一点才有用。app/trace.py 检查的是句子,而不是模型。这意味着,负责生成句子的部分可以随时替换:今天用 gemma3:4b,明天用更小的模型,或经过微调的模型,而检查器提供的保障保持不变。这比「我们用了一个好模型」强得多,而且只有当两端都掌握在自己手里时,你才能拥有这样的特性。
用量一上来,按调用计费就显得不划算。一个服务五位客户的自由职业者,每周都要写五份这样的汇报,而且会一直写下去。这里的边际成本,只是一台本来就开着的笔记本所消耗的电。
而我能做成这一切的原因,是本地运行让我可以直接查看原始输出。本文中的每一个发现——把版本变化说反、把文档 commit 说成功能实现,以及我自己检查关卡里的三次误报——都来自在一台由我掌控的机器上,逐行查看模型实际返回了什么。一个汇总分数会把这些全都藏起来。
Gemma 的最佳应用。 写作层用的是 Google 的 gemma3:4b,通过 Ollama 在一台没有 GPU 的八核笔记本上,用 CPU 本地运行。正是它,让这个项目以现在的形态成为可能。模型擅长我不擅长的事:把零散的一周整理成句子。但它也会在一个具体、可衡量的地方失准:它倾向于挑选最能给工作增光、又勉强说得通的动词。app/trace.py 里的每条规则,都是对我亲眼看见 Gemma 在真实 commit 上犯下的某种错误的回应。因为本地运行不花钱,我才能把这些失败案例反复跑够 36 次,逐条阅读,分清哪十三条被关卡抓住了,哪四条是它从结构上就无法发现的。
本文中的所有 commit、句子和评测,都来自合成的 fixture repo 和我自己的项目。没有使用任何客户代码,也没有任何 repo 离开本机。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。