diff --git a/AGENTS.md b/AGENTS.md index 01f2cb8..cb949be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,4 +225,6 @@ samples/ - 2026-11-05:补遗追加(FR-M2-10,`docs/architecture/addendum-plan.md`,AD1–AD6 全部按建议确认)。招标文件解析后可以追加补遗 / 澄清(单独解析、扣 1 次),新条目标「补遗 N」进正式清单、清单回到草稿;同一种日期时间不同的旧条目标「已被补遗 N 更新」,项目字段的新值在概览「采用 / 不采用」,采用截止时间时日期条目一起确认,没确认前查标用旧值、检查预览提示。补遗不改项目名称、编号、采购人等(更正公告标题会被抽成项目名称)。真实模型对虚构更正公告抽得对;还没有真实补遗样本。 - 2026-11-05:读标、查标全流程走查(浏览器从注册走到导出报告)。修了走查发现的问题:原生下拉换成按规范 §7.21 的 `SelectBox`(Reka UI,和搜索框同高,长选项不再挤压签名);关键日期去重;报告页致命数改读项目的实时值;★ 只是「重要」的文件清单项也挂到 ★ 规则上;检查范围文案过期;报告出处不重复文件名(模板 2026.11-v3)。查标结果页按原型补齐:本次处理的留在原位、J / K / O / 1 / 2 / 3 / X / U 键盘、「提示」批量确认、「判断有误?」反馈、覆盖范围默认收起、导出报告入口;项目头改为「就绪 / 未就绪」,致命数挂在查标标签上。 - 2026-11-05:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`components/DocStage.vue`):当前的占满,另一份露出一窄条(文件名、纸边、带方向箭头的标签,截断处淡入底色),点它或点另一边的出处就滑过去,两边各自记着页和高亮,J / K 时原文跟着翻。概览把关键日期和采购包合成一张卡放到最上面。修了原文查看器直接打开到某页时重复渲染出镜像乱字的问题。内容区四角改为直角(设计规范 §5.3 同步)。 -- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库;有更正公告一起要);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 +- 2026-11-05:国动真实投标文件跑了一次(`check-eval.md` §14,代码 `5b77012`、规则集 r4、资质库空):重新读标 299 条,查标 26 秒报 44 个问题,逐条对原文后有用 9 个,**致命、重要各 1 个都是误报**(响应格「完全满足,无偏离。」被当成错位;「不……另行收费」被当成附加条件)。另外扫描页只按整页有没有字判断、不需要回答的条目还在出提示。人工看到评分索引表页码和实际页码对不上,产品还不查这一项。结果只记录,6 处问题修不修待用户定。 +- 2026-11-05:按用户决定(E3)修了国动暴露的 6 处,规则集 2026.11-r5(`check-eval.md` §15)。国动重新检查 44 → 11 个,全是提示级,有用的 9 个都还在(看过结果才修,只作参考)。开发集不退化:召回 75% → 74%(模型重跑的波动),致命级 25 / 32 不变,规矩的投标文件报出的问题 239 → 213,规则问题 57 / 57。投标分册的文字层升到 v5(每页记图片占比,用来判断扫描件)。 +- 下一步:可做的有:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 diff --git a/docs/architecture/check-eval.md b/docs/architecture/check-eval.md index 70d74a3..846939d 100644 --- a/docs/architecture/check-eval.md +++ b/docs/architecture/check-eval.md @@ -309,7 +309,66 @@ **局限**:资质情况表和词表是同一个人对着同一批样本写的,分数说明不了泛化;真实投标文件的资质表写法、扫描件比例都没测。国动真实投标文件到了以后,请用户按公司的真实证书录资质库再跑一次(`qualification-plan.md` §5)。 -## 14. 下一步 +## 14. 国动真实投标文件(2026-11-05,跑一次) + +用户提供的真实响应文件(南京长卿科技有限公司,183 页 PDF,WPS 导出;第 18–33 页里有 23 页是没有文字的扫描件,第 104–169 页是合同和劳动合同的扫描件,每页带一行文字页眉)。招标文件用当前代码重新读标(DOC 扩展名、实际是 DOCX,71 页,45 秒,299 条、待确认 3 条,响应方式技术 / 商务都认成「逐条响应」)。用户看过清单、没有改动,资质库空,直接查标。代码是 `5b77012`(规则集 `2026.11-r4`、提示词 `check.match` v2)。文件放在 `samples/tenders/GD/`,不进 git。 + +**结果**:26 秒,模型调用 31 次(输入 15.6 万 token、输出 2.1 万);249 条参与检查,203 条判「已响应」。报出 44 个问题:致命 1、重要 1、提示 42。没有金标准,下表是逐条对照投标文件原文的人工判断(Claude 判,用户未复核): + +| 问题 | 个数 | 判断 | +|---|---|---| +| 响应错位(含唯一的致命) | 15 | **全部误报**。响应格写「完全满足,无偏离。」,`POSITIVE` 去掉「完全满足,」后剩下「无偏离。」,不在 `MARKER` 里,被当成「别的内容」。真实投标文件最常见的写法,开发集、留出集没有 | +| 附加条件(重要) | 1 | 误报。原句是「……均包含在合同价中,**不**以正常接口对接和适配工作**另行收费**」,是承诺不是附加条件 | +| 出处没能核对 | 4 | 误报。部署分区(第 72 页)、架构图(第 61–63 页)、人员经验和岗位(第 132–133 页)原文都有 | +| 未找到响应 | 10 | 5 条有用:平台费用三条(商务表里确实没有逐条回应)、没有承诺延长质保(评分项)、没有声明演示系统是自主研发;5 条误报:售后、实施、数据治理三条「方案应明确……」对应的章节都有,响应时限承诺第 100 页写了 15 分钟 / 1 小时,「合同签订后细化进度计划」不是投标时要回答的 | +| 疑似不满足 | 1 | 误报。团队配置第 132–133 页列了 3 名核心人员和分工,模型的说明「只写了满足」不对 | +| 扫描件兜底「请人工核对」 | 12 | 4 条有用:第二十二条的证明材料(确实在扫描页上),以及软件自主研发、团队专业能力、国产化适配三个评分项(投标文件里确实没有材料,人员证书一栏都写「无」,但提示说「可能在扫描件里」,说法不对);8 条是噪音:不接受联合体、无特定资格要求、须具备资格要求、联合体协议、特定资格证明,以及残疾人福利单位、监狱企业、本国产品三种价格扣除,都不需要这家投标人回答 | + +合计有用 9 个、误报或噪音 35 个;**致命、重要级 2 个全是误报**。报价规则(大小写、分项合计 43.2 万、低于限价 45 万)、落款日期、有效期都没有报问题,原文核对也确实没有问题。 + +**产品没查、人工看到的真问题**:第 3 页「评分索引表」写的页码和实际页码对不上(表上写到第 209 页,文件只有 183 页,「履约能力」写的是第 124–194 页,实际是第 102–169 页),总目录也一样。评委按索引翻页会找不到。这属于「目录页码和实际页码一致」,覆盖范围里还没有这一项。 + +**暴露的问题**(按规矩只记录,修不修由用户定): + +1. **响应错位的肯定写法**:「完全满足,无偏离」「满足,无偏离;」这类复合写法要整体当成肯定回答。影响最大,唯一的致命就是它。 +2. **附加条件的否定句**:「不……另行收费」「不以……为由另行……」是承诺,不算附加条件。 +3. **扫描件判断只看整页有没有字**:第 104–169 页的扫描件因为有一行页眉,不算扫描页,所以提示写「23 页扫描件」,实际有 80 多页。另外,评分项要的材料文字部分没有时,提示一律说「可能在扫描件里」,即使扫描页全是别的材料也这么说。 +4. **「要不要投标人回答」漏判**:联合体、特定资格「无」、价格扣除这些条目(投标人没有声称适用的),以及合同签订后才做的事,不该出问题。 +5. **评分细则的「方案应明确……」**:投标文件有整章对应时,模型在候选句里没找到。这类条目要整章去对,不是逐句去找。 +6. **出处核对**:4 条模型找对了页,引文没能核对上,降成了「未找到」。 + +**读法**:真实文件上的问题数(44)不多,原因是这份投标文件很规矩:偏离表逐条照抄、全写「完全满足」。但最高两级全是误报,说明规则在没见过的写法上还很脆。召回测不出来,因为这份文件本身没有致命问题,第 1、2 条的误报正好落在最显眼的位置。 + +## 15. 修国动暴露的 6 处(2026-11-05,决策 E3) + +规则集 `2026.11-r5`。每处都配了单元测试,例句不取自国动、开发集或留出集。 + +1. **响应错位的复合肯定写法**(`check/deviation.py`):响应格开头连着写的肯定回答(「完全满足,无偏离。」「符合,无负偏离;」)一起去掉,剩下有内容的才拿来和要求比;「完全满足,显示器升降行程:150mm」照报。 +2. **附加条件的否定句**(`check/rules.py`):「不以……另行收费」「不以……为由另行计取」是承诺;「另行收取」「另行协商」照报。 +3. **扫描件判断**(`text_layer` v5,`check/match.py`、`findings.py`):每页记图片占比。字数不到 20,或图片占两成以上且不到 60 字,或占四成半以上且不到 120 字,都算扫描页(带插图的正文页字多,不算)。提示写明扫描件在哪几页,例如「投标文件第 16–23、26–33、104–131 页是扫描件(共 82 页)……材料如果附在这些页上,请人工核对,没有附就是缺了」,不再说「材料可能在扫描件里」。投标分册下次检查时按 v5 重新抽文字层,句子编号不变。 +4. **不需要这家投标人回答的条目**(`check/applicability.py`):写明没有的(「无特定资格要求」;有这条时,「特定资格要求的证明文件」也不查)、总括性的「须具备招标文件规定的资格要求」、标题以「合同签订后 / 中标后」开头的,都不找响应。条件性要求(标题里是联合体、残疾人福利性单位、监狱企业、本国产品的)要投标文件里出现了对应的文件才查。「不接受联合体」不算「没有要求」:投标人真是联合体时恰恰要报。这些都写进检查覆盖范围。 +5. **提纲式要求**(`check/match.py`):「方案应明确 / 包括 A、B、C……」里列出的每一项(至少 3 项)再各检索 1 句,候选句最多 10 句,模型能看到整章的内容。 +6. **同一批片段的出处核对**(`check/match.py`、`reuse.py`、`runner.py`):同一批送给模型的片段,任何一条都可以引用,引文照样要和原文核对;出处没过校验的结论不再沿用到下次检查(以前沿用回来会变成普通的「未找到」)。 + +**国动(看过结果才修的,只作参考)**:重新检查报出 11 个问题,全是提示级,用时 7 秒(大部分结论沿用上次,只调了 2 次模型)。§14 里判为有用的 9 个都还在,剩 2 个误报:团队配置的「只写了满足」、更优响应时限(这两条的结论是沿用上次的)。原来的 4 条出处核对失败、「方案应明确……」的 4 条都改判已响应,出处页码是对的(第 61–63、79–85、87、94–101、132 页)。 + +**开发集不退化**(17 份,运行 `r5` 对比 `r4`): + +| 指标 | r4 | r5 | +|---|---|---| +| 查标召回 | 112 / 149(75%) | 111 / 149(74%) | +| 致命级召回 | 25 / 32 | 25 / 32 | +| 规矩的投标文件报出的问题 | 239 | 213 | +| 其中落在不需要响应的要求上 | 79 | 53 | +| 规则问题 | 57 / 57,误报 4 | 57 / 57,误报 4 | + +召回少的 1 个是模型重跑的波动:跳过的条目变了以后,批次重新组合,大部分批次没命中缓存,模型重新判了一遍(各类此消彼长:D01 +1、D05 +1、D03 −1、D06 −2)。漏掉的 4 个,把第 6 处临时关掉(全部走缓存)结论一样,不是修复造成的。派生的报价、资质评测文件(8 份)的规则和资质问题和上次完全一样。 + +**留出集(看过,只作参考)**:召回 51 → 49 / 74,致命级 26 → 26 / 41,规矩的投标文件报出的问题 82 → 78,规则问题 19 / 20 不变。少的 2 个(S06-C 的 D09、S09-C 的 D01)关掉第 6 处后结论也一样,同样是重新组批后模型判法变了。 + +**局限**:这 6 处都是看着一份真实文件改的。第 4 处的「条件性要求」只认标题里的这几种主体;第 3 处的阈值是按国动这一份文件的扫描页定的,别的文件扫描页的版式(整页图片、多张小图)还没见过。 + +## 16. 下一步 1. 分项报价表和采购需求清单逐项对照(P13)**暂缓**:在开发集 7 份招标文件上试读「名称 + 数量」的采购清单表,S02、S08(服务类)没有这种表;S04 把「是否进口」一栏读成了名称;S10 采购包 2 的表跨页、第二页没有表头,漏了大半;S01 9 行里能和投标文件的分项对上名称的只有 7 行。P13 是致命级,按这个抽取质量在真实文件上会大量误报「缺项」,评测集里也没有注入过,先不上,覆盖范围里照写「还没有上线」。散文写法的单价上限同样暂缓。 2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。 diff --git a/docs/decisions.md b/docs/decisions.md index 4bf7a62..f671646 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,12 @@ --- +## E3 · 2026-11-05 · 国动真实投标文件暴露的 6 处全部修 + +- **决策**:国动真实投标文件跑完一次(`check-eval.md` §14)后,暴露的 6 处全部修:响应错位的复合肯定写法、附加条件的否定句、扫描件判断、不需要这家投标人回答的条目、提纲式的「方案应明确……」、同一批片段的出处核对。规则集 `2026.11-r5`。 +- **理由**:用户决定。致命、重要两级报出来的都是误报,而且都是真实投标文件最常见的写法。 +- **影响**:国动的正式成绩以 §14(修之前)为准,修后的数字只作参考(看过结果才修的)。真实文件仍然只有这一份,泛化要等更多真实投标文件。 + ## Q2(修订)· 2026-11-05 · ★ 一律按实质性条款处理 | # | 决策 | 理由 | diff --git a/evals/check/run_check.py b/evals/check/run_check.py index 87a1add..c2221de 100644 --- a/evals/check/run_check.py +++ b/evals/check/run_check.py @@ -265,7 +265,8 @@ async def main() -> None: cat, mode = pair.split("=") # 模拟清单页上人工确认 / 改过响应方式(C12 的人工确认点) m = modes[cat] modes[cat] = response_mode.Mode(cat, mode, "found", m.explicit, m.implicit) - decisions = {i: applicability.decide(p, modes) for i, p in items.items()} + known = applicability.context(items.values(), (s.text for d in docs for s in d.sentences)) + decisions = {i: applicability.decide(p, modes, known) for i, p in items.items()} items = {i: p for i, p in items.items() if decisions[i].match} results, stats = await match.run(ctx, uuid4(), items, docs, progress) if not args.no_direct: diff --git a/server/src/bid_doctor/check/applicability.py b/server/src/bid_doctor/check/applicability.py index cfca119..d70d634 100644 --- a/server/src/bid_doctor/check/applicability.py +++ b/server/src/bid_doctor/check/applicability.py @@ -5,13 +5,18 @@ - 无效情形、评审规定(只有「无效」类别、不是标注出来的条款):由规则检查(第 2 步)和人工核对覆盖; - 日期与递交:由规则检查(落款日期、有效期)和需人工核对覆盖; - 评分标准本身:没写要附什么材料的不找;写了的按证明材料找; -- 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找。 +- 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找; +- 写明没有的要求(无特定资格要求)、总括性的「须具备招标文件规定的资格要求」、 + 中标或签订合同以后才做的事,不找; +- 条件性要求(联合体、残疾人福利性单位 / 监狱企业 / 本国产品的价格扣除):投标文件里有对应的文件、 + 说明投标人声称适用时才找(国动真实投标文件,check-eval §14)。 其余按类别取招标文件的响应方式:技术参数用技术类,其余用商务类。 """ from __future__ import annotations import re +from collections.abc import Iterable from dataclasses import dataclass from typing import Any @@ -22,15 +27,50 @@ EVIDENCE = re.compile(r"(提供|附|出具)[^。;]{0,20}(证明|报告|截图| # 「签订合同时提供」才算中标后;「以合同签订时间为准」(业绩条款常见)不是 AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)(?!间)|合同签订(前|时)(?!间))") NO_PROOF = re.compile(r"无需提供|不需提供|无须提供|采购人[^。;]{0,12}(查询|核查|查验)|信用中国|信用记录") +# 写明没有要求、不接受:没有要投标人提供的东西(按标题判断,标题是读标写的一句话概括) +# 「不接受联合体」不放这里:投标人真是联合体时恰恰要报,按条件性要求处理 +NONE_REQUIRED = re.compile(r"无特定资格要求|特定资格要求[::]?(无|/)") +SPECIAL_PROOF = re.compile(r"特定资格要求的证明") # 招标文件写了「无特定资格要求」时不用交 +UMBRELLA = re.compile( + r"(具备|满足|符合)[^。;]{0,4}(招标|磋商|采购|谈判|询价)文件[^。;]{0,4}(规定|要求)的?资格(要求|条件)" +) +AFTER_AWARD = re.compile(r"^(合同签订后|签订合同后|中标后|成交后)") +# 条件性要求:标题里的主体 → 投标文件里出现了就说明投标人声称适用 +# (「残疾人福利性单位声明函」写「不属于」也算出现,照常查) +CONDITIONAL = { + "consortium": (re.compile(r"联合体"), re.compile(r"联合体(协议|牵头|各方|成员)")), + "disabled": (re.compile(r"残疾人福利"), re.compile(r"残疾人福利性单位")), + "prison": (re.compile(r"监狱|戒毒"), re.compile(r"监狱企业|戒毒企业")), + "domestic": (re.compile(r"本国产品"), re.compile(r"本国产品")), +} SKIP_LABELS = { "condition": "无效情形和评审规定", "schedule": "日期与递交要求", "scoring": "评分标准(没要求附证明材料)", "no_proof": "资格条件(写明无需提供证明,或由采购人查询)", "price": "报价类要求(分项、单价上限):由规则比对分项报价表", + "none_required": "写明没有的要求(无特定资格要求)", + "umbrella": "总括性的资格要求(由各条具体资格条件覆盖)", + "after_award": "中标或签订合同以后才做的事", + "not_claimed": "条件性要求(联合体、残疾人福利性单位、监狱企业、本国产品),投标文件没有声称适用", } +@dataclass(frozen=True, slots=True) +class Context: + """整份清单和投标文件上的事实。claims 为 None 时不知道投标文件写了什么,条件性要求照常查。""" + + claims: frozenset[str] | None = None + no_special: bool = False # 招标文件写了「无特定资格要求」 + + +def context(items: Iterable[dict[str, Any]], bid_texts: Iterable[str]) -> Context: + bid = "".join(bid_texts) + claims = frozenset(k for k, (_, claimed) in CONDITIONAL.items() if claimed.search(bid)) + no_special = any(re.search(r"无特定资格要求|特定资格要求[::]?(无|/)", str(i.get("title") or "")) for i in items) + return Context(claims, no_special) + + @dataclass(frozen=True, slots=True) class Decision: match: bool @@ -40,15 +80,26 @@ class Decision: evidence: bool = False # 要求投标时附证明材料:默认接受也不能免(没附就是没附) -def decide(item: dict[str, Any], modes: dict[str, Mode]) -> Decision: +def decide(item: dict[str, Any], modes: dict[str, Mode], ctx: Context | None = None) -> Decision: cats = set(item.get("categories") or []) - text = f"{item.get('title') or ''}{item.get('requirement_text') or ''}" + title = str(item.get("title") or "") + text = f"{title}{item.get('requirement_text') or ''}" if cats == {"invalidity"} and item.get("importance_basis") != "marker": return Decision(False, skip="condition") # 「……的,投标无效」这类情形;★ / 斜体下划线标注的条款是要求,照常找 if cats == {"price"}: return Decision(False, skip="price") if "schedule" in cats and not cats & {"tech_param", "qualification", "scoring"}: return Decision(False, skip="schedule") + if NONE_REQUIRED.search(title) or (ctx is not None and ctx.no_special and SPECIAL_PROOF.search(title)): + return Decision(False, skip="none_required") + if "tech_param" not in cats and UMBRELLA.search(text): + return Decision(False, skip="umbrella") + if AFTER_AWARD.search(title): + return Decision(False, skip="after_award") + if ctx is not None and ctx.claims is not None: + for key, (subject, _) in CONDITIONAL.items(): + if subject.search(title) and key not in ctx.claims: + return Decision(False, skip="not_claimed") evidence = bool(item.get("evidence_required") or EVIDENCE.search(text)) and not AWARD.search(text) if cats <= {"scoring"} and not evidence: return Decision(False, skip="scoring") diff --git a/server/src/bid_doctor/check/deviation.py b/server/src/bid_doctor/check/deviation.py index 7706ff9..ece1921 100644 --- a/server/src/bid_doctor/check/deviation.py +++ b/server/src/bid_doctor/check/deviation.py @@ -192,6 +192,10 @@ def direct_read(results: dict[UUID, Result], docs: list[BidDoc]) -> int: POSITIVE = re.compile(r"^(满足|完全满足|响应|完全响应|已响应|符合|同意|接受)[,,::。;、\s]*") MARKER = re.compile(r"^(无偏离|正偏离|负偏离|偏离|满足|完全响应|响应|是|否|/|—+|-+)$") +# 「完全满足,无偏离。」「符合,无偏离;」:几个肯定写法连着写,整格仍是肯定回答(国动真实投标文件,check-eval §14) +AFFIRM = re.compile( + r"^(完全满足|满足|完全响应|已响应|响应|完全符合|符合|同意|接受|无偏离|无负偏离|不偏离|完全一致|一致)[,,::。;;、\s]*" +) POINTER = re.compile(r"详见|参见|见附件|见第|第.{0,6}页|如下|见下|见上|附后|另附|承诺") GENERIC = re.compile(r"^(支持|具备|提供|有|可以|能|是|均支持|已具备|可实现|实现)[。;;]?$") # 笼统的肯定回答 CJK = re.compile(r"[一-鿿]") @@ -213,6 +217,16 @@ def _similar(a: str, b: str) -> float: CERT_NAME = re.compile(r"(证书|许可证|资质|认证)$") +def _beyond_affirm(text: str) -> str: + """去掉开头连着写的肯定回答(「完全满足,无偏离;」),剩下的才是响应格里另外写的内容。""" + t = norm(text) + while m := AFFIRM.match(t): + if not m.group(0): + break + t = t[m.end() :] + return t + + def _cert_name(text: str, item: dict[str, object]) -> bool: """资质情况表的名称格(「安全生产许可证」):比要求短、以证书名结尾,右边是编号、有效期,不是照抄的要求格。""" t = norm(text) @@ -252,7 +266,7 @@ def mismatch_read(results: dict[UUID, Result], items: dict[UUID, dict[str, objec if not cells: continue d, _, resp = cells[0] - extra = POSITIVE.sub("", norm(resp.text)) + extra = _beyond_affirm(resp.text) if len(extra) < 2 or POINTER.search(extra) or GENERIC.match(extra) or _similar(extra, req) > 0: continue r.verdict = "not_found" diff --git a/server/src/bid_doctor/check/findings.py b/server/src/bid_doctor/check/findings.py index df1e83f..9797f03 100644 --- a/server/src/bid_doctor/check/findings.py +++ b/server/src/bid_doctor/check/findings.py @@ -114,10 +114,15 @@ def _spec(result: Result, item: dict[str, Any]) -> Spec | None: return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"}) if result.verdict == "scanned": total = result.detail.get("scanned_total") or 0 + where = result.detail.get("scanned_where") + # 只说扫描件在哪几页,不说「材料可能在扫描件里」:扫描件上是不是这份材料,没有 OCR 分不出来 text = ( - f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字," + f"投标文件文字部分没有找到这份材料。{where} 是扫描件(共 {total} 页),系统读不出上面的文字;" + "材料如果附在这些页上,请人工核对,没有附就是缺了。" + if where + else f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字," "材料可能在扫描件页上,请人工核对。" ) - return Spec("manual_review", "hint", f"请人工核对(可能在扫描件里):{name}", text, base) + return Spec("manual_review", "hint", f"请人工核对(文字部分没找到):{name}", text, base) why = REASONS.get(result.reason_code or "", result.reason_code or "") return Spec("manual_review", sev, f"未判定:{name}", f"这条要求本次没有完成判断({why}),请人工核对。", base) diff --git a/server/src/bid_doctor/check/match.py b/server/src/bid_doctor/check/match.py index a55b2b7..6c783ff 100644 --- a/server/src/bid_doctor/check/match.py +++ b/server/src/bid_doctor/check/match.py @@ -37,6 +37,11 @@ SPLIT_DEPTH = 2 # 定在 5 倍左右:正常文件碰不到,异常大的文件不会无限花钱;超出的条目列为「未判定」,写进覆盖范围 MAX_INPUT_CHARS = 1_200_000 CONCURRENCY = 4 # TODO(第 3 步):和解析共用「供应商 × 租户」统一限流(extract.py 的同一个 TODO) +# 「方案应明确数据标准、更新路径、接口边界、联调安排和异常处理措施」:要的是投标文件里一章的内容,整句检索的 +# 前几名常落在别处;列出的每一项再各检索一次,候选句才覆盖得到各项(国动真实投标文件,check-eval §14) +OUTLINE = re.compile(r"(方案|计划|措施)[^。;,]{0,6}(应当|应|须|需)(明确|包括|包含|说明|涵盖|阐述)([^。;]*)") +OUTLINE_SPLIT = re.compile(r"、|以及|及|和|与") +OUTLINE_MAX = 10 # 这类条目的候选句上限(整句检索的 5 句 + 每项 1 句) SID = re.compile(r"^d(\d+)p(\d+)s(\d+)$") END_PUNCT = "。;;,,、::))" VERDICTS = {"responded", "not_found", "negative_deviation", "suspected_unmet", "no_value"} @@ -109,13 +114,25 @@ def scanned_fallback( pages = [f"{d.label} 第 {p} 页" for d in docs for p in d.scanned_pages] if not pages: return + where = ";".join(f"{d.label}第 {page_ranges(d.scanned_pages)} 页" for d in docs if d.scanned_pages) for item_id, d in modes.items(): r = results.get(item_id) cats = set(items.get(item_id, {}).get("categories") or []) if r is None or r.verdict != "not_found" or not (d.evidence or cats & {"qualification", "scoring"}): continue r.verdict, r.evidence_status, r.reason_code = "scanned", "none", None - r.detail = {**r.detail, "scanned_pages": pages[:30], "scanned_total": len(pages)} + r.detail = {**r.detail, "scanned_pages": pages[:30], "scanned_total": len(pages), "scanned_where": where} + + +def page_ranges(pages: list[int]) -> str: + """[16, 17, 18, 20] → 「16–18、20」""" + out: list[str] = [] + for p in sorted(set(pages)): + if out and p == int(out[-1].split("–")[-1]) + 1: + out[-1] = f"{out[-1].split('–')[0]}–{p}" + else: + out.append(str(p)) + return "、".join(out) def short_sid(doc: BidDoc, s: Sentence) -> str: @@ -130,6 +147,17 @@ def query_text(item: dict[str, Any]) -> str: return " ".join(parts) +def outline_terms(item: dict[str, Any]) -> list[str]: + """「方案应明确 A、B、C……,并确保……」里列出的各项;少于 3 项的不算提纲式要求。""" + m = OUTLINE.search(str(item.get("requirement_text") or item.get("title") or "")) + if not m: + return [] + body = re.split(r"[,,]|并|确保|以便", m.group(4))[0] + parts = [x.strip("的等") for x in OUTLINE_SPLIT.split(body)] + terms = [x for x in parts if 2 <= len(x) <= 12] + return terms[:8] if len(terms) >= 3 else [] + + def _item_block(key: str, item: dict[str, Any], sids: list[str]) -> str: cats = "、".join(CATEGORY_LABELS.get(str(c), str(c)) for c in item.get("categories") or []) lines = [f"[{key}] {item.get('title') or ''}"] @@ -262,6 +290,12 @@ async def run( results[item_id] = Result("responded", "presence", "verified", evidence=found[: len(wanted)]) continue hits = index.search(query_text(item), CANDIDATES) + seen = {id(h.sentence) for h in hits} + for term in outline_terms(item): + for h in index.search(term, 1): + if id(h.sentence) not in seen and len(hits) < OUTLINE_MAX: + hits.append(h) + seen.add(id(h.sentence)) if not hits: # 检索不到候选句:不调模型(m3-plan §2.1) results[item_id] = Result("not_found", "retrieval", detail={"search_scope": scope}) continue @@ -325,8 +359,11 @@ async def run( await progress(done[0] / total) return answers = {str(a.get("item")): a for a in data.get("results") or [] if isinstance(a, dict)} + # 同一批里的片段只送一次,模型常引用别的条目的候选句(往往就是这条的响应):本批送出的片段都可以引用, + # 引文照样要过原文校验(国动真实投标文件 4 条 sid_unknown,check-eval §14) + shown = {sid: x for _, _, _, allowed in keyed for sid, x in allowed.items()} for key, item_id, _, allowed in keyed: - r = judge(answers.get(key), allowed, scope) + r = judge(answers.get(key), shown | allowed, scope) if r.reason_code and r.reason_code != "model_error": stats.fail(r.reason_code) results[item_id] = r diff --git a/server/src/bid_doctor/check/reuse.py b/server/src/bid_doctor/check/reuse.py index 130673c..729c4d8 100644 --- a/server/src/bid_doctor/check/reuse.py +++ b/server/src/bid_doctor/check/reuse.py @@ -50,6 +50,8 @@ def restore(stored: dict[str, Any], docs: list[BidDoc], run_id: str) -> Result | """按上次存下的证据位置在本次的投标文件里找回句子(原文也要一样),找不全返回 None。""" from bid_doctor.check.match import Result + if (stored.get("detail") or {}).get("claimed_verdict"): + return None # 出处没过校验的结论(r5 以前也存了):不沿用,重新问 by_pos: dict[tuple[str, int, int], tuple[BidDoc, Sentence]] = { (str(d.id), s.page_no, s.seq): (d, s) for d in docs for s in d.sentences } diff --git a/server/src/bid_doctor/check/rules.py b/server/src/bid_doctor/check/rules.py index 13e5adf..62547fc 100644 --- a/server/src/bid_doctor/check/rules.py +++ b/server/src/bid_doctor/check/rules.py @@ -1000,6 +1000,8 @@ COMMERCIAL = re.compile( NOT_CONDITION = re.compile( r"(不|无|未|免|不再|无需|不得|决不|不会)[^。;,]{0,4}(另行|另计|另议)|无效|否则|不予|不接受|免费|不影响|不增加" r"|(不|不得|不会|决不)以任何理由" + # 「不以正常接口对接和适配工作另行收费」「不以维护、适配或缺陷修复为由另行收费」:承诺,不是附加条件 + r"|(不|不得|不会|决不|不再)以[^。;,]{0,24}(另行|另计|另议)" ) diff --git a/server/src/bid_doctor/check/runner.py b/server/src/bid_doctor/check/runner.py index aadd7de..b7fac80 100644 --- a/server/src/bid_doctor/check/runner.py +++ b/server/src/bid_doctor/check/runner.py @@ -184,7 +184,8 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None: items[item_id] = payload else: other_lot += 1 # 不在所投包里的条目不查,写进覆盖范围(C6) - decisions = {i: applicability.decide(p, modes) for i, p in items.items()} + known = applicability.context(snapshot.values(), (x.text for d in bid_docs for x in d.sentences)) + decisions = {i: applicability.decide(p, modes, known) for i, p in items.items()} skipped: dict[str, int] = {} for d in decisions.values(): if not d.match and d.skip: @@ -200,7 +201,12 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None: await _stage(ctx, run_id, "match", pct) results, stats = await match.run(ctx, run_id, items, bid_docs, progress, prior) - raw = {i: reuse.snapshot(x) for i, x in results.items() if x.layer == "model" and x.verdict != "undetermined"} + # 出处没过校验的不沿用:下次检查重新问(沿用回来会丢掉「出处没能核对」的状态) + raw = { + i: reuse.snapshot(x) + for i, x in results.items() + if x.layer == "model" and x.verdict != "undetermined" and x.evidence_status != "unverified" + } deviation.direct_read(results, bid_docs) # 证据所在的表格行里偏离格写着「偏离」:改判负偏离 deviation.mismatch_read(results, items, bid_docs) # 证据只是照抄的要求格、响应格写的是别的:改判未找到 match.apply_modes(results, items, decisions) diff --git a/server/src/bid_doctor/check/service.py b/server/src/bid_doctor/check/service.py index 49e3dae..a194ab6 100644 --- a/server/src/bid_doctor/check/service.py +++ b/server/src/bid_doctor/check/service.py @@ -37,8 +37,8 @@ from bid_doctor.project import service as projects from bid_doctor.project.models import Project CHECK_TASK = "check_project" -# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配 -RULE_SET_VERSION = "2026.11-r4" +# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配;r5:国动真实文件暴露的 6 处 +RULE_SET_VERSION = "2026.11-r5" WAIVE_WINDOW = timedelta(hours=24) ERROR_MESSAGES = { "bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线", diff --git a/server/src/bid_doctor/document/pipeline/normalize.py b/server/src/bid_doctor/document/pipeline/normalize.py index 1d48dac..11dbc3e 100644 --- a/server/src/bid_doctor/document/pipeline/normalize.py +++ b/server/src/bid_doctor/document/pipeline/normalize.py @@ -24,7 +24,8 @@ from bid_doctor.document.pipeline.text_layer import Page from bid_doctor.document.service import doc_prefix # text_layer v2:页眉页脚带单独成句,句子编号和 v1 不同(同一份规范化 PDF 在 v1、v2 下的编号不能混用) -PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v4", "sentence_split": "s3-v4"} +# text_layer v5:每页记图片占比(扫描件判断),句子和编号和 v4 相同;投标分册下次检查时重新抽一遍 +PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v5", "sentence_split": "s3-v4"} OCR_REQUIRED_RATIO = 0.5 # 超过一半的页没有文字层:招标文件按「需要 OCR」失败 OnStage = Callable[[str], Awaitable[None]] diff --git a/server/src/bid_doctor/document/pipeline/text_layer.py b/server/src/bid_doctor/document/pipeline/text_layer.py index dca4b8b..29ca855 100644 --- a/server/src/bid_doctor/document/pipeline/text_layer.py +++ b/server/src/bid_doctor/document/pipeline/text_layer.py @@ -123,10 +123,18 @@ class Page: rotation: int char_count: int sentences: list[Sentence] + image_cover: float = 0.0 # 图片占页面的比例(text_layer v5 起有;更早的文字层读出来是 0) @property def text_source(self) -> str: - return "text" if self.char_count >= 20 else "empty" + """扫描件页算「没有文字层」(empty)。扫描件常带一行文字说明 + (「合同扫描件第 2 页(共 15 页)」「2.1 营业执照」),只看字数会漏掉(国动真实投标文件 60 多页, + check-eval §14):图片占两成以上、不到 60 字,或占四成半以上、不到 120 字,也算扫描件; + 带插图的正文页字多,不算。""" + n, cover = self.char_count, self.image_cover + if n < 20 or (cover >= 0.2 and n < 60) or (cover >= 0.45 and n < 120): + return "empty" + return "text" def to_json(self) -> dict[str, Any]: return { @@ -135,6 +143,7 @@ class Page: "height_pt": self.height, "rotation": self.rotation, "char_count": self.char_count, + "image_cover": self.image_cover, "sentences": [s.to_json() for s in self.sentences], } @@ -147,6 +156,7 @@ class Page: rotation=d["rotation"], char_count=d["char_count"], sentences=[Sentence.from_json(d["page_no"], s) for s in d["sentences"]], + image_cover=d.get("image_cover", 0.0), ) @@ -337,9 +347,22 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], infos: list rotation=int(page.rotation or 0) % 360, char_count=len(chars), sentences=sentences, + image_cover=_image_cover(page), ) +def _image_cover(page: Any) -> float: + """图片占页面面积的比例(重叠的不去重,封顶 1),判断扫描件用。""" + area = float(page.width) * float(page.height) + total = 0.0 + for im in page.images: + w = min(float(page.width), float(im["x1"])) - max(0.0, float(im["x0"])) + h = min(float(page.height), float(im["bottom"])) - max(0.0, float(im["top"])) + if w > 0 and h > 0: + total += w * h + return round(min(total / area, 1.0), 3) if area else 0.0 + + def extract(doc_id: str, pdf: bytes) -> list[Page]: with pdfplumber.open(io.BytesIO(pdf)) as doc: pages = [(p, *_fragments([c for c in p.chars if str(c["text"]).strip()], _cells(p))) for p in doc.pages] diff --git a/server/tests/test_check_rules.py b/server/tests/test_check_rules.py index ac25215..9ce957d 100644 --- a/server/tests/test_check_rules.py +++ b/server/tests/test_check_rules.py @@ -356,3 +356,21 @@ def test_long_response_mentioning_noncompliance_is_not_negative() -> None: assert deviation.judge([s("部分响应")], "投标响应情况") == "negative" assert deviation.judge([s("不满足,现有版本仅支持单机部署")], "投标响应情况") == "negative" assert deviation.judge([s("无偏"), s("离")], "偏离") == "ok" + + +def test_negated_extra_charge_is_a_promise_not_a_condition() -> None: + """「不以……另行收费」「不以……为由另行计取」是承诺(国动真实投标文件误报,check-eval §14); + 真的附加了条件(「另行收取」「另行协商」)照报。""" + d = doc( + [ + para(1, "数据迁移和接口改造均包含在合同价中,不以正常适配工作另行收费。"), + para(2, "质保期内不以系统升级、兼容调整为由另行计取费用,售后服务全部免费。"), + para(3, "现场驻场服务费用按天另行收取,不含在本次报价中。"), + para(4, "超出质保期的维护费用由双方另行协商确定。"), + ] + ) + hits, _ = rules.run(rules.Facts(), [d], []) + assert sorted((h.code, h.evidence[0][1].seq) for h in hits) == [ + ("FORM.CONDITION_ADDED", 3), + ("FORM.CONDITION_ADDED", 4), + ] diff --git a/server/tests/test_check_units.py b/server/tests/test_check_units.py index 994401b..6d91243 100644 --- a/server/tests/test_check_units.py +++ b/server/tests/test_check_units.py @@ -4,6 +4,8 @@ from __future__ import annotations from uuid import uuid4 +import pytest + from bid_doctor.check import findings, match from bid_doctor.check.retrieval import Index, tokens from bid_doctor.document.pipeline.text_layer import Sentence @@ -158,15 +160,16 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None: b: {"categories": ["tech_param"], "title": "内存"}, } decisions = {a: applicability.Decision(True, evidence=True), b: applicability.Decision(True)} - docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13])] + docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13, 14, 20])] match.scanned_fallback(results, items, decisions, docs) spec = findings.spec(results[a], items[a]) assert ( results[a].verdict == "scanned" and spec is not None and spec.severity == "hint" - and "2 页扫描件" in spec.explanation + and "资格证明册第 12–14、20 页 是扫描件(共 4 页)" in spec.explanation ) + assert match.page_ranges([5, 3, 4, 9]) == "3–5、9" assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到 @@ -240,6 +243,11 @@ def test_mismatch_read_only_when_response_cell_is_unrelated() -> None: ("支持端口汇聚、端口流量统计", "满足,支持端口汇聚和端口流量统计"), ("批量数据处理在非工作时段内完成", "满足,详见技术方案第12页"), ("系统支持国产操作系统", "满足"), + # 几个肯定写法连着写(国动真实投标文件,check-eval §14):仍是肯定回答 + ("支持按区域、距离和服务类型筛选并排序", "完全满足,无偏离。"), + ("后台支持批量导入并提示错误行", "符合,无负偏离;"), + ("支持离线缓存最近浏览的十条记录", "完全满足,无偏离;页面截图详见附件三"), + ("短信验证码有效期不超过五分钟", "完全满足,显示器升降行程:150mm"), ] sents, results, items = [], {}, {} for n, (req, resp) in enumerate(rows): @@ -254,9 +262,18 @@ def test_mismatch_read_only_when_response_cell_is_unrelated() -> None: results[k] = (a, b) d = match.BidDoc(uuid4(), 1, "技术分册", sents) res = {k: match.Result("responded", "ai", "verified", evidence=[(d, a)]) for k, (a, _) in results.items()} - assert deviation.mismatch_read(res, items, [d]) == 1 + assert deviation.mismatch_read(res, items, [d]) == 2 verdicts = [r.verdict for r in res.values()] - assert verdicts == ["not_found", "responded", "responded", "responded"] + assert verdicts == [ + "not_found", + "responded", + "responded", + "responded", + "responded", + "responded", + "responded", + "not_found", + ] first = next(iter(res.values())) assert first.detail["mismatch"] == "显示器升降行程:150mm" and first.reason_code == "response_mismatch" @@ -346,3 +363,111 @@ def test_direct_read_uses_header_column_and_flags_unclear_values() -> None: assert results[b].verdict == "responded" spec = findings.spec(results[a], {"title": "支持双电源", "importance": "substantive", "categories": ["tech_param"]}) assert spec is not None and spec.severity == "important" and spec.title.startswith("偏离栏需核对") + + +def test_scanned_page_with_caption_counts_as_empty() -> None: + """扫描件常带一行文字说明(「合同扫描件第 2 页(共 15 页)」):按图片占比和字数一起判断(check-eval §14)。""" + from bid_doctor.document.pipeline.text_layer import Page + + def page(chars: int, cover: float) -> str: + return Page(1, 595, 842, 0, chars, [], cover).text_source + + assert page(0, 0.0) == "empty" + assert page(26, 0.28) == "empty" # 「2.1 营业执照」+ 执照扫描图 + assert page(97, 0.53) == "empty" # 一段说明 + 整页合同扫描 + assert page(30, 0.0) == "text" # 只有一行字的正文页(没有图) + assert page(495, 0.25) == "text" # 带架构图的正文页 + assert page(179, 0.45) == "text" # 图多、字也不少 + assert ( + Page.from_json( + {"page_no": 1, "width_pt": 1, "height_pt": 1, "rotation": 0, "char_count": 30, "sentences": []} + ).text_source + == "text" + ) # v4 文字层没有图片占比 + + +def test_applicability_skips_conditions_the_bidder_does_not_claim() -> None: + """不需要这家投标人回答的条目(check-eval §14):写明没有的、总括性的、中标后才做的、 + 投标文件没声称适用的条件性要求(联合体、价格扣除)。声称了(投标文件里有对应文件)就照常查。""" + from bid_doctor.check import applicability + + def it(title: str, cats: list[str], req: str = "") -> dict[str, object]: + return {"title": title, "categories": cats, "requirement_text": req or title} + + items = [ + it("本项目第2包无特定资格要求", ["qualification"]), + it("提交特定资格要求的证明材料", ["qualification"], "特定资格要求的证明材料(如有,必须提供)"), + it("投标人应满足招标文件规定的资格条件", ["qualification"]), + it("中标后十日内提交详细实施计划", ["tech_param"]), + it("联合体投标的,须提交联合体协议书", ["qualification"]), + it("残疾人福利性单位价格扣除", ["scoring"], "提供《残疾人福利性单位声明函》,给予价格扣除。"), + it("提供营业执照副本复印件", ["qualification"]), + ] + ctx = applicability.context(items, ["营业执照副本复印件", "我公司不属于残疾人福利性单位。"]) + got = [applicability.decide(i, {}, ctx).skip for i in items] + assert got == ["none_required", "none_required", "umbrella", "after_award", "not_claimed", None, None] + # 没有「无特定资格要求」时特定资格证明照查;不知道投标文件写了什么(ctx 为空)时条件性要求照查 + assert applicability.decide(items[1], {}, applicability.Context(frozenset())).skip is None + assert applicability.decide(items[4], {}).skip is None + consortium = applicability.context(items, ["联合体协议书(附后)"]) + assert applicability.decide(items[4], {}, consortium).skip is None + + +async def test_citation_from_another_item_in_the_same_batch_is_verified(monkeypatch: pytest.MonkeyPatch) -> None: + """片段每批只送一次,模型引用了同一批别的条目的候选句:引文核对得上就算(check-eval §14); + 没送过的编号照样是 sid_unknown。""" + from bid_doctor.core.db import TenantContext + + net = sent(1, "系统部署于互联网区与电子政务外网区,两区之间只经前置交换。", page=7) + mem = sent(2, "服务器内存配置为64GB,满足不低于32GB的要求。", page=9) + d = match.BidDoc(uuid4(), 1, "投标文件", [net, mem]) + a, b = uuid4(), uuid4() + items = { + a: {"title": "系统部署在电子政务外网区", "categories": ["tech_param"]}, + b: {"title": "服务器内存不低于32GB", "categories": ["tech_param"]}, + } + # 前提:每条只取 1 个候选句时,b 的候选句里没有 net(net 只是 a 的候选句) + assert [h.sentence for h in Index([net, mem]).search(match.query_text(items[b]), 1)] == [mem] + + async def fake(ctx: object, **kw: object) -> dict[str, object]: + ev = {"sid": "d1p7s1", "quote": "系统部署于互联网区与电子政务外网区"} + return { + "results": [ + {"item": "I1", "verdict": "responded", "evidence": [ev]}, + {"item": "I2", "verdict": "responded", "evidence": [ev]}, # 引的是 a 的候选句 + ] + } + + monkeypatch.setattr(match, "CANDIDATES", 1) + monkeypatch.setattr(match.ai, "run_json", fake) + + async def progress(_: float) -> None: + return None + + ctx = TenantContext(tenant_id=uuid4(), user_id=uuid4()) + results, _ = await match.run(ctx, uuid4(), items, [d], progress) + assert results[a].verdict == "responded" and results[b].verdict == "responded" + assert results[b].evidence[0][1] is net + + unknown = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p8s1", "quote": "x" * 10}]}, {}, []) + assert unknown.reason_code == "sid_unknown" + + +def test_outline_requirement_is_searched_item_by_item() -> None: + """「方案应明确 A、B、C……」:列出的每一项再各检索一次(check-eval §14)。""" + item = {"requirement_text": "运维方案应包括巡检频次、备份策略、故障上报流程以及应急演练安排,并确保措施可落地。"} + assert match.outline_terms(item) == ["巡检频次", "备份策略", "故障上报流程", "应急演练安排"] + assert match.outline_terms({"requirement_text": "方案应明确工期。"}) == [] + assert match.outline_terms({"requirement_text": "支持巡检、备份和应急演练。"}) == [] + + +def test_reuse_skips_conclusions_whose_citation_failed() -> None: + """出处没过校验的结论不沿用(复查时重新问模型);正常的结论按证据位置找回来。""" + from bid_doctor.check import reuse + + d, allowed = bid() + (_, s) = allowed["d1p1s9"] + ok = match.Result("responded", "model", "verified", evidence=[(d, s)]) + assert reuse.restore(reuse.snapshot(ok), [d], "r1") is not None + failed = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p9s9", "quote": "x" * 10}]}, allowed, []) + assert reuse.restore(reuse.snapshot(failed), [d], "r1") is None