feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行

- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 16:44:22 +08:00
parent 47c6a1258e
commit db1b1f6f9f
17 changed files with 1792 additions and 20 deletions

View File

@@ -213,4 +213,5 @@ samples/
- 2026-11-04:查标第一轮改进:候选句带同一行的单元格、提示词 check.match v2。开发集 17 份上召回 30% → 50%,带标志词的问题 2% → 54%,误报略降,输入 token +44%;漏响应和响应对象错位变差(`docs/architecture/check-eval.md` §3)。评测集修了待修项、加了 3 份低密度投标文件,第三批复核 55 条、用户抽查 11 条全部同意。
- 2026-11-04:查标第二轮:评测集纠错(漏响应、错位的「变差」多是生成器只改了一处);新增响应方式识别(C12,规则优先)和「这条要不要投标人回答」的判断。开发集 17 份上规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32;证明材料缺失几乎查不出(扫描件)。第四批复核 45 条,用户抽查 9 条全部同意(四批共 65 条)。见 `docs/architecture/check-eval.md` §4。
- 2026-11-04:查标第三轮:文件要在(按标题找文件名,不调模型)、扫描件兜底(C10,没找到的证明材料改为提示级「可能在扫描件里,请人工核对」)、响应方式存进清单并在清单页显示、可改(C12 的人工确认点,迁移 0007、0008)。开发集上规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32);证明材料缺失仍查不出,要靠 OCR / 多模态(S5)。见 `docs/architecture/check-eval.md` §5。
- 下一步:查标第 2 步的投标侧结构识别和规则检查(报价形式、形式要件)。
- 2026-11-04:查标第四轮:规则检查(`check/rules.py`,规则集 2026.11-r1,不调模型)——大小写金额、分项合计、超限价、多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;文字层的句子带上单元格框,表格按单元格分行。请 Fable 从真实投标文件角度审读,按「认不准的不给致命」改了定级和取值范围。开发集 17 份上规则问题 57 / 57 查出、规矩投标文件 0 误报,但规则是看着生成器和开发词库写的,这个分数说明不了泛化,要等留出集和国动真实投标文件。见 `docs/architecture/check-eval.md` §6。
- 下一步:查标第 2 步剩下的(需人工核对的条目和 `manual-checks` 接口、「报价」类清单项和分项上限 C7),然后第 3 步的 D09 / D06 提示词 v3。

View File

@@ -194,12 +194,14 @@ async function start() {
<ul>
<li>正式清单每一条在投标文件里有没有响应:未找到响应、负偏离未说明、疑似不满足</li>
<li>每条结论都带投标文件的原文出处;出处核对不上的,不算「已响应」</li>
<li>报价形式:大写和小写金额、分项报价合计、超过最高限价、另外给的报价</li>
<li>形式要件:别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应</li>
</ul>
</div>
<div class="no">
<h3>这一版还不查</h3>
<ul>
<li>报价形式(三层限价、合计、大小写)、形式要件、资质有效期:规则检查还没有上线</li>
<li>分项 / 单价上限、分项和采购需求逐项对照、格式文件固定文字被改、声明函、资质有效期:还没有上线</li>
<li>签章真伪(以后也只看有无,不鉴定真伪)</li>
<li>份数、密封、CA 加密、递交方式(文件里看不出来)</li>
<li>报价是否合理、该报多少,评分高低</li>

View File

@@ -144,7 +144,40 @@
**读法**:问题的个数没变,变的是级别:规矩的投标文件上的致命问题少了一半多(120 → 51),多出来的是「请到扫描件里核对」的提示,致命级召回不变。总召回掉了 4 个点,主要是批次变化带来的波动(文件要在的条目不再进模型,同一批里的组合变了),以及几条漏响应被「标题里出现了文件名」误判为已响应——要盯着。评测集里证明材料全是扫描件页,所以证明材料缺失(D02)照旧查不出、扫描件兜底也分不出缺没缺;真正查出来要靠 OCR / 多模态(S5)。
## 6. 下一步
## 6. 第四轮:规则检查(报价形式、形式要件)(2026-11-04)
1. 第 2 步(投标侧结构识别、规则、C12 响应方式抽取)和第 3 步(整行上下文、偏离表直取、presence 判断)按 §2.2 的顺序改,每改一轮在开发集 14 份上重跑对比。
**改了什么**(`check/rules.py`,规则集 `2026.11-r1`,不调模型):
- **投标侧结构**:按坐标把句子分成行、按表头归栏,认三种表——开标一览表(「投标总报价」那一栏)、分项报价表(数量 / 单价 / 合价三栏,可以跨页接续)、响应表(有「响应情况 / 偏离情况」一栏);再加落款日期、格式文件的填写位、页眉。
- **规则**:大写和小写金额(P04)、分项算术和合计(P05)、超最高限价(P01 / P02,按所投采购包取限价)、多个报价(P12)、页眉和填写位里的别的项目名称 / 编号 / 采购人(P06)、没填的地方(P07)、落款日期晚于截止或写错(P08)、投标有效期短于要求(P09)、附加条件(P10)、响应表里写「未完全响应 / 负偏离」或空着(D10)。响应匹配已经对同一句报了负偏离的,规则不重复报。
- 每条问题挂投标原文;有依据的挂招标侧出处(限价、截止时间、项目名称来自项目基本信息,有效期要求来自清单项)。缺项目侧的值(限价、截止时间、有效期要求)或认不出开标一览表时不报,写进覆盖范围。
- 说明只写读到的数字和依据,不写「应改为多少」(红线 6)。
**顾问审读后的修改**(请 Fable 从真实政府采购投标文件的角度审读,只给代码不给结论)。它指出的主要风险是真实版式上的致命误报,按「宁可让人核对,不让规矩的投标文件挂致命」改了:
- **表格分行改按单元格**:文字层原来就用 pdfplumber 找出了单元格框,但没存到句子上;现在句子带上单元格框(可选字段,旧的文字层没有就按句子重叠分行),同一行按单元格上沿分。垂直居中的短格、折成几句的长格、纵向合并的格不再串行或错位。
- **开标一览表认得更严**:总价那一栏下面恰好一个金额才算总价,分项表、多年度报价表的金额栏叫「投标报价」也不会被当成「多个总价」;不同位置写的总价不一致改为重要级的「投标总价前后写的不一致」,不再按致命的「两个报价」报。
- **另报价按把握分级**:「备选方案 / 方案二 / 第二套」致命;「优惠价 / 让利后」重要;金额等于投标总价(开标一览表或分项合计)的不报;否定看整个分句。
- **分项表**:「硬件小计」这类分组小计不进明细;同一栏读到两个数的行(无框线表格的合并格)不核对;下一页重复表头接着算同一张表;说明写「相差多少元」。
- **响应表**:一条记录从序号行到下一个序号行;「空着」一律按重要报(可能是版面没认准);认「否」(「是否响应 / 满足」栏)、「不响应(见说明)」这类写法。
- **投标有效期**:只在投标函、开标一览表那一页上的按致命报,别处按提示;复述的招标原文不算;「本报价有效期」(原厂报价单)不算;要求有几条时取最少的天数;认表格写法(「投标有效期」一格、旁边或下面一格写天数)。
- **别的项目名称**:页眉要像项目名称(以「项目 / 工程 / 采购 / 标段」结尾或带编号),章节页眉不算;招标文件里出现过的名称、编号(代理机构编号、简称)不算;「致:贵单位」不算。
- **附加条件**:复述判断先去掉编号、用前后 20 字;「不影响合同价款」这类不算;技术分册里的按提示报。
- **大写金额**:前后两句都找、只认写着「小写 / ¥ / 报价 / 合计」的句子,任何一处相等就不报。
- 落款日期认汉字数字(「二〇二六年十一月十七日」)和「日 期:」;说明里去掉「……的投标无效」这类绝对说法,改为「通常会导致投标无效,请核对」。复查对齐的目标改为原文前缀 + 摘要,前缀相同的两句不会互相吞掉。
- 每类都加了开发集以外的反例单元测试(`test_check_rules.py`)。
**评测集修正**:P07 的注入记录改成空白处前后的原文(原来记段落开头,空白在同段后一句时对不上);S07-A-sloppy 的 D10 注入在人员表里(不是响应表),剔除,生成器改为只往表头有「响应」的表里注入。score.py 加了规则问题的评分:规则编号对上、投标侧出处里有注入的原文才算查出。
| 指标(17 份) | 结果 |
|---|---|
| 规则问题查出 | 57 / 57(P04 8、P05 7、P06 7、P07 10、P08 7、P09 1、P10 7、P12 7、D10 3) |
| 规则问题误报 | 0(规矩的投标文件 7 份上 0) |
| 规则问题的级别 | 致命 8(备选方案类另报价 6、投标函上的有效期 1、符合性响应表 1)、重要 32、提示 17 |
| 响应匹配 | 不变(召回 92 / 150,致命级 22 / 32) |
**读法:这个满分基本说明不了泛化。** 规则是在知道生成器怎么注入、见过开发词库措辞的情况下写的,投标文件版式也都出自同一个生成器。附加条件(P10)和多个报价(P12)的开发词库措辞直接落在规则的写法里,最需要留出集检验。为此做了两件事:规则按招投标文件的通用写法归纳(单元测试和 `bid-mini-forms.pdf` 都用开发词库以外的措辞);评测集里没有的情形(超限价、跨页分项表、万元单位)只有单元测试。真实投标文件的合并单元格、扫描页、各地模板差异,要等国动真实投标文件来看。P01 / P02 / P03 / P11 / P13–P18 评测集里还没有注入。
## 7. 下一步
1. 第 2 步剩下的:需人工核对的条目(份数、密封、递交方式)和 `manual-checks` 接口;「报价」类清单项和分项 / 单价上限(C7、P03);评测集补注入 P01、P02、P13。
2. 第 3 步:D09(响应对象错位)、D06(负偏离未说明)的提示词 v3,token 预算、复查复用、重复条目分组。
3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。

View File

@@ -10,7 +10,9 @@
|---|---|---|
| 第 0 步 评测集 | 试跑完成,继续扩充 | 规范 v0.2、留出集划分已定,留出集词库和风格 C 已封存;生成、注入、渲染、离线查标、评分工具已写;开发集 7 份招标文件、14 份投标文件第一版跑完,结果和发现见 `check-eval.md` §2 |
| 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 |
| 第 2~6 步 | 未开始 | |
| 第 2 步 规则 | 进行中 | 响应方式(C12)、文件要在、扫描件兜底、规则检查 `rules.py`(报价形式、形式要件、响应表)已上;需人工核对的条目、「报价」类清单项和分项上限(C7)还没做。见 `check-eval.md` §4–§6 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2;D09 / D06、token 预算、复查复用还没做 |
| 第 4~6 步 | 未开始 | |
**第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):

View File

@@ -334,6 +334,8 @@ class Mutator:
def compliance(self) -> None:
for t in self.tables(fixed=True):
if not any("响应" in str(h) for h in t.get("header") or []):
continue # 只有响应表(表头有「响应情况」栏)才注入;人员表、业绩表不是
rows = [r for r in t["rows"] if self.free(r.get("responds") or [])]
if not rows:
continue
@@ -388,7 +390,7 @@ class Mutator:
b = self.rng.choice(paras)
blank = self.phrase("P07", "blanks")
b["text"] = b["text"].replace(bidder, blank, 1)
self.rule("P07", "投标人名称空", b["text"][:24])
self.rule("P07", "投标人名称空", blank_context(b["text"], blank))
def p08(self) -> None:
signs = [b for b in self.blocks if b.get("type") == "sign"]
@@ -423,6 +425,13 @@ class Mutator:
self.rule("P12", text, text)
def blank_context(text: str, blank: str) -> str:
"""空白处前后的原文(不跨句号),评分时拿它对照问题的投标侧出处。"""
i = text.index(blank)
start = max(i - 10, max(text.rfind(x, 0, i) for x in "。;") + 1)
return text[start : i + len(blank) + 10]
def table_mode(b: dict[str, Any]) -> str:
"""表格的响应方式:档案里表单写了 mode 的以它为准(如 S02 评分要的技术规格表是 presence,不是逐条响应的机制)。"""
return str(b.get("mode") or ("implicit" if b.get("only_deviations") else "explicit"))
@@ -473,7 +482,7 @@ def main() -> None:
for f in OUT.glob(f"{tender}-*/manifest.json"):
for uid, e in json.loads(f.read_text())["units"].items():
if (e.get("review") or {}).get("ok") is False:
m.rejected.add(uid) # noqa: S311 评测数据,不用于安全
m.rejected.add(uid)
if not a.manifest_only:
if a.density == "high":
m.d01_block()

View File

@@ -30,6 +30,10 @@ def main() -> None:
traps: Counter[str] = Counter()
tokens = [0, 0]
findings_clean = items_clean = 0
r_found: Counter[str] = Counter()
r_total: Counter[str] = Counter()
r_fp: Counter[str] = Counter()
r_fp_clean = r_clean = 0
for bid in a.bids:
d = OUT / bid
m = json.loads((d / "manifest.json").read_text())
@@ -43,6 +47,13 @@ def main() -> None:
if "查标" in s["recall"]:
by_style[m["style"]] += s["recall"]["查标"][0]
by_style_tot[m["style"]] += s["recall"]["查标"][1]
rules = s.get("rules") or {}
r_found.update(rules.get("found") or {})
r_total.update(rules.get("total") or {})
r_fp.update(rules.get("fp_by_code") or {})
if m["kind"] == "clean":
r_fp_clean += rules.get("fp", 0)
r_clean += 1
if m["kind"] == "clean":
fp += s["fp"]
ok += s["ok_units"]
@@ -68,6 +79,15 @@ def main() -> None:
f"- 规矩的投标文件里,不需要响应(na / unknown)的要求上报了问题 {other} 个,对不上标注单元的问题 {unmapped} 个",
f"- 干扰项误报:{dict(traps)}",
f"- token:输入 {tokens[0]:,},输出 {tokens[1]:,}({len(a.bids)} 次检查)",
"",
f"## 规则问题:查出 {pct(sum(r_found.values()), sum(r_total.values()))};误报 {sum(r_fp.values())} 个"
f"(其中规矩的投标文件 {r_clean} 份上 {r_fp_clean} 个)",
"",
"| 问题 | 查出 |",
"|---|---|",
*[f"| {k} | {pct(r_found[k], r_total[k])} |" for k in sorted(r_total)],
"",
f"- 误报按规则:{dict(r_fp)}",
]
print("\n".join(lines))

View File

@@ -11,11 +11,13 @@ from __future__ import annotations
import argparse
import asyncio
import datetime as dt
import hashlib
import json
import pathlib
import re
import sys
from decimal import Decimal
from typing import Any
from uuid import UUID, uuid4
@@ -23,8 +25,9 @@ from bid_doctor.ai import prompts
from bid_doctor.ai import service as ai_service
from bid_doctor.ai.providers.base import near_limit
from bid_doctor.ai.service import OutputIncomplete, provider
from bid_doctor.check import applicability, findings, match
from bid_doctor.check import applicability, findings, match, rules
from bid_doctor.checklist import response_mode
from bid_doctor.checklist.extract import amount_yuan
from bid_doctor.core.config import get_settings
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline import text_layer
@@ -102,6 +105,9 @@ def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]:
text = "".join(by_sid[k].text for k in r.get("sentences") or [] if k in by_sid)
raw = (r.get("raw") or {}).get("tech_param") or {}
spec = {"tech_param": {k: raw[k] for k in ("param", "op", "value", "unit") if raw.get(k)}} if raw else {}
sched = (r.get("raw") or {}).get("schedule") or {}
if sched:
spec["schedule"] = {k: sched[k] for k in ("date_kind", "at") if sched.get(k)}
out[UUID(int=i + 1)] = {
"title": r.get("title"),
"requirement_text": text or None,
@@ -114,6 +120,53 @@ def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]:
return out
def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Facts:
"""项目侧的值:读标评测抽出的项目基本信息(和产品里一样来自抽取);所投的包按招标档案(相当于用户选包)。"""
base = EVALS / "out" / "runs" / run / tender
info = json.loads((base / "info.json").read_text())
f = {k: v.get("value") for k, v in info["fields"].items()}
profile = json.loads((OUT / "tenders" / f"{tender}.json").read_text())["project"]
deadline = next(
(
dt.date.fromisoformat(s["at"][:10])
for r in json.loads((base / "items.json").read_text())
if r.get("zone") == "formal"
and (s := (r.get("raw") or {}).get("schedule") or {}).get("date_kind") == "bid_deadline"
and s.get("at")
),
None,
)
lots = info.get("lots") or []
ceiling, scope, note = None, "total", None
if lots:
lot = next(
(x for x in lots if str(x["lot_no"]).lstrip("0") == str(profile.get("bidding_lot", "")).lstrip("0")), None
)
if lot is None and len(lots) == 1:
lot = lots[0]
if lot is not None:
c = amount_yuan((lot.get("ceiling") or {}).get("value"), (lot.get("ceiling") or {}).get("unit"))
ceiling, scope = (Decimal(c) if c else None), "lot" if len(lots) > 1 else "total"
else:
note = "所投采购包不明确,报价没有和限价比对"
else:
c = amount_yuan(f.get("ceiling_total"), info["fields"].get("ceiling_total", {}).get("unit"))
ceiling = Decimal(c) if c else None
days, item = rules.required_validity(items)
return rules.Facts(
name=f.get("name"),
code=f.get("code"),
purchaser=f.get("purchaser"),
agency=f.get("agency"),
deadline=deadline,
ceiling=ceiling,
ceiling_scope=scope,
ceiling_note=note,
validity_days=days,
validity_item=item,
)
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("bid")
@@ -122,6 +175,7 @@ async def main() -> None:
ap.add_argument("--baseline", action="store_true", help="只检索、不调模型的基线")
ap.add_argument("--v1", action="store_true", help="第 1 步的做法:提示词 v1、不带同一行的单元格(对比基线)")
ap.add_argument("--no-modes", action="store_true", help="不按响应方式和「要不要回答」筛选(C12 之前的做法)")
ap.add_argument("--rules-only", action="store_true", help="只重跑规则检查,写回已有运行的 results.json")
args = ap.parse_args()
d = OUT / args.bid
manifest = json.loads((d / "manifest.json").read_text())
@@ -136,8 +190,39 @@ async def main() -> None:
pdf.stem,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
[s for p in pages for s in p.sentences if s.location == "header_footer"],
)
)
hits, rules_cov = rules.run(
facts(manifest["tender"], args.checklist, items), docs, gold.sentences(manifest["tender"])
)
def rule_rows(covered: set[tuple[str, int, str]]) -> list[dict[str, Any]]:
return [
{
"code": h.code,
"type": h.type,
"severity": h.severity,
"title": h.title,
"explanation": h.explanation,
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in h.evidence],
}
for h in rules.drop_covered(hits, covered)
]
if args.rules_only:
prev = d / "runs" / args.name / "results.json"
data = json.loads(prev.read_text())
covered = {
(e["volume"], e["p"], "".join(e["text"].split()))
for it in data["items"]
if it["verdict"] == "negative_deviation"
for e in it["evidence"]
}
data["rules"], data["rules_coverage"] = rule_rows(covered), rules_cov
prev.write_text(json.dumps(data, ensure_ascii=False, indent=1))
print(f"{args.bid}:规则问题 {len(data['rules'])} 个")
return
ai_service.run_json = baseline_run_json if args.baseline else cached_run_json # type: ignore[assignment]
if args.v1:
match.PROMPT = ("check.match", 1)
@@ -196,7 +281,19 @@ async def main() -> None:
out = d / "runs" / args.name
out.mkdir(parents=True, exist_ok=True)
summary = {"items": len(rows), "usage": usage, "verify_failures": stats.verify_failures, "splits": stats.splits}
(out / "results.json").write_text(json.dumps({"summary": summary, "items": rows}, ensure_ascii=False, indent=1))
covered = {
(doc.label, s.page_no, "".join(s.text.split()))
for res in results.values()
if res.verdict == "negative_deviation"
for doc, s in res.evidence
}
(out / "results.json").write_text(
json.dumps(
{"summary": summary, "items": rows, "rules": rule_rows(covered), "rules_coverage": rules_cov},
ensure_ascii=False,
indent=1,
)
)
findings_n = sum(r["finding"] is not None for r in rows)
print(
f"{args.bid}:清单 {len(rows)} 条,问题 {findings_n} 个,模型调用 {usage['calls']} 次(缓存 {usage['cached']}),"

View File

@@ -44,6 +44,83 @@ FAMILY = {
"D15": {"qualification", "unresponded"},
}
FATAL_CATS = {"substantive", "qualification"}
RULE_CODES = {
"P01": "PRICE.OVER_CEILING_TOTAL",
"P02": "PRICE.OVER_CEILING_LOT",
"P03": "PRICE.OVER_CEILING_ITEM",
"P04": "PRICE.CAPITAL_MISMATCH",
"P05": "PRICE.SUM_MISMATCH",
"P06": "FORM.FOREIGN_PROJECT",
"P07": "FORM.BLANK_FIELD",
"P08": "FORM.DATE_AFTER_DEADLINE",
"P09": "FORM.VALIDITY_SHORT",
"P10": "FORM.CONDITION_ADDED",
"P11": "FORM.MISSING_DOC",
"P12": "PRICE.MULTIPLE_QUOTES",
"P13": "PRICE.ITEM_MISSING",
}
def squash(t: str) -> str:
return "".join(t.split())
def score_rules(manifest: dict[str, Any], res: dict[str, Any]) -> dict[str, Any]:
"""规则问题(P 类):规则编号对上、投标侧出处里有注入的那段原文才算查出;没对上任何注入问题的规则问题算误报。
D10(符合性条件响应表)按表级问题记在 rule_defects 里,它由响应匹配查(negative_deviation),这里一起对照。"""
hits = list(res.get("rules") or [])
used: set[int] = set()
found: Counter[str] = Counter()
total: Counter[str] = Counter()
missed: list[str] = []
for rd in manifest.get("rule_defects") or []:
kind, snip = rd["type"], squash(rd.get("t") or "")
total[kind] += 1
if kind == "D10":
pool = [
(-1, {"evidence": it["evidence"]})
for it in res["items"]
if it["finding"] and it["finding"]["type"] == "negative_deviation"
] + [(i, h) for i, h in enumerate(hits) if h["code"] == "FORM.RESPONSE_NEGATIVE"]
else:
pool = [(i, h) for i, h in enumerate(hits) if h["code"] == RULE_CODES.get(kind)]
key = snip[:12]
cell = squash(rd.get("variant") or "") if kind == "D10" else ""
ok = next(
(
i
for i, h in pool
if any(
(key and (key in squash(e["text"]) or squash(e["text"]) in snip))
or (cell and squash(e["text"]) == cell)
for e in h["evidence"]
)
),
None,
)
if ok is None:
missed.append(f"{rd['id']}({rd.get('variant', '')}):{rd.get('t', '')[:40]}")
continue
found[kind] += 1
if ok >= 0:
used.add(ok)
# 响应表规则报的「负偏离」对上了响应问题(D05 / D06)的注入位置:算那个单元查出(rule_units),不算规则误报
rule_units: set[str] = set()
for i, h in enumerate(hits):
if h["code"] != "FORM.RESPONSE_NEGATIVE":
continue
texts = [squash(e["text"]) for e in h["evidence"]]
for uid, u in manifest["units"].items():
if u.get("expect") != "defect" or "negative_deviation" not in FAMILY.get(u.get("defect", ""), set()):
continue
if any(
(w := squash(x.get("t") or "")[:12]) and any(w in t or (t and t in w) for t in texts)
for x in u.get("where") or []
):
rule_units.add(uid)
used.add(i)
fp = [h for i, h in enumerate(hits) if i not in used]
return {"found": found, "total": total, "missed": missed, "fp": fp, "n": len(hits), "units": rule_units}
def pct(a: int, b: int) -> str:
@@ -79,6 +156,7 @@ def main() -> None:
parent[find(o)] = find(first)
group = {u.id: find(u.id) for u in units}
rs = score_rules(manifest, res)
per_unit: dict[str, list[dict[str, Any]]] = defaultdict(list)
unmapped: list[dict[str, Any]] = []
for it in res["items"]:
@@ -120,6 +198,9 @@ def main() -> None:
continue
rec_total["查标"] += 1
good = [it for it in found if it["finding"]["type"] in FAMILY.get(e["defect"], set())]
if not good and any(x in rs["units"] for x in manifest["units"] if group.get(x, x) == g):
good = [{"finding": {"type": "negative_deviation", "severity": "important"}}]
rec["其中由响应表规则查出"] += 1
if good:
for k in [*keys, "端到端", "查标"]:
rec[k] += 1
@@ -182,7 +263,23 @@ def main() -> None:
*[f"- {x}" for x in fp[:80]],
*(["- ……"] if len(fp) > 80 else []),
"",
f"## 规则问题(第 2 步才查,这里只列出):{len(manifest['rule_defects'])} 个",
]
lines += [
f"## 规则问题:注入 {sum(rs['total'].values())} 个,查出 {sum(rs['found'].values())} 个;规则报出 {rs['n']} 个,误报 {len(rs['fp'])} 个",
"",
"| 问题 | 查出 |",
"|---|---|",
*[f"| {k} | {pct(rs['found'][k], rs['total'][k])} |" for k in sorted(rs["total"])],
"",
*[f"- 漏:{x}" for x in rs["missed"]],
*[
f"- 误报:{h['code']} {h['title']}:{h['evidence'][0]['text'][:50] if h['evidence'] else ''}"
for h in rs["fp"]
],
"",
"### 规则检查没覆盖的",
"",
*[f"- {x}" for x in (res.get("rules_coverage") or {}).get("not_checked", [])],
"",
]
out = d / "runs" / a.run
@@ -196,6 +293,13 @@ def main() -> None:
"other_findings": other_findings,
"extraction_miss": len(extraction_miss),
"include_unreviewed": a.include_unreviewed,
"rules": {
"found": dict(rs["found"]),
"total": dict(rs["total"]),
"fp": len(rs["fp"]),
"fp_by_code": dict(Counter(h["code"] for h in rs["fp"])),
"n": rs["n"],
},
}
(out / "score.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1))
print("\n".join(lines[:20]))

View File

@@ -53,6 +53,7 @@ class BidDoc:
label: str
sentences: list[Sentence]
scanned_pages: list[int] = field(default_factory=list[int]) # 没有文字层的页(扫描件),C10
header_footer: list[Sentence] = field(default_factory=list[Sentence]) # 页眉页脚(规则检查看页眉里的项目名称)
@dataclass(slots=True)

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,17 @@
- 和解析任务同一套做法(架构 §3.4):阶段之间检查取消标记;结局由错误类别决定扣不扣点,失败、取消都退回。
- 健全性检查:投标文件几乎读不出文字时按输入问题失败,不扣点、不生成问题(参考项目的同类闸门)。
- 认不出、查不了、没查的都写进检查覆盖范围(coverage),不静默跳过(m3-plan §2.5)。
TODO(第 2 步):规则检查(报价形式、形式要件、需人工核对);投标侧结构识别(偏离表、开标一览表、格式文件)。
- 规则检查(报价形式、形式要件)在 rules.py,项目侧的值(限价、截止时间、有效期要求)带招标侧出处,见 _facts。
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import delete, func, select
@@ -20,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.ai.models import AiCall
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing
from bid_doctor.check import applicability, findings, match
from bid_doctor.check import applicability, findings, match, rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.check.service import RULE_SET_VERSION, open_fatal
from bid_doctor.checklist import edit as checklist_edit
@@ -35,13 +37,16 @@ from bid_doctor.document import service as documents
from bid_doctor.document.citations import document_label
from bid_doctor.document.models import SourceDocument
from bid_doctor.document.pipeline import normalize, text_layer
from bid_doctor.document.pipeline.text_layer import norm
from bid_doctor.notification.models import Notification
from bid_doctor.project import service as projects
from bid_doctor.project.models import Lot, Project, ProjectField
log = logging.getLogger(__name__)
PROGRESS = {"prepare_bid": 5, "match": 30, "rules": 85, "verify": 90, "carry_over": 93, "summarize": 96}
MIN_BID_CHARS = 200 # 所有分册加起来读不出这么多字:按「投标文件几乎读不出文字」失败
REGIMES_VERIFIED = {"gov_procurement"} # 招标投标体系没有样本,规则未经验证(m3-plan §2.4)
LOCAL_TZ = ZoneInfo("Asia/Shanghai") # 投标截止时间按北京时间取日期
class Cancelled(Exception):
@@ -126,6 +131,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
label,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
[x for p in pages for x in p.sentences if x.location == "header_footer"],
)
)
pages_info.append(
@@ -163,6 +169,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
[text_layer.Page.from_json(pg) for pg in await documents.text_layer(s, ctx, d)]
)
]
facts = await _facts(s, ctx, r, snapshot)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
@@ -191,7 +198,15 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, bid_docs)
await _stage(ctx, run_id, "rules") # 第 2 步:报价形式、形式要件、需人工核对
await _stage(ctx, run_id, "rules")
hits, rule_cov = rules.run(facts, bid_docs, tender_sents)
covered = {
(d.label, x.page_no, norm(x.text))
for res in results.values()
if res.verdict == "negative_deviation"
for d, x in res.evidence
}
hits = rules.drop_covered(hits, covered)
await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点
await _stage(ctx, run_id, "carry_over")
async with tenant_tx(ctx) as s:
@@ -201,6 +216,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
d.id: d for d in (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars()
}
await _write(s, ctx, r, items, results, by_doc)
await _write_rules(s, ctx, r, hits, facts, by_doc)
await _carry_over(s, ctx, r, set(items))
_stage_nowait(r, "summarize")
tokens = (
@@ -211,7 +227,9 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
)
).one()
r.counts = await _counts(s, run_id)
r.coverage = _coverage(r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes)
r.coverage = _coverage(
r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes, rule_cov
)
await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"])
@@ -219,6 +237,90 @@ def _stage_nowait(r: CheckRun, stage: str) -> None:
r.stage, r.progress = stage, PROGRESS[stage]
async def _facts(s: AsyncSession, ctx: TenantContext, r: CheckRun, snapshot: dict[UUID, dict[str, Any]]) -> rules.Facts:
"""规则检查要的项目侧值:项目基本信息(人工改过的为准)、所投采购包的限价、清单里的投标有效期要求,各带招标侧出处。"""
p = (await s.execute(select(Project).where(Project.id == r.project_id))).scalar_one()
fields = {
f.field_key: f for f in (await s.execute(select(ProjectField).where(ProjectField.project_id == p.id))).scalars()
}
def value(key: str) -> Any:
f = fields.get(key)
return None if f is None else f.human_value if f.human_value is not None else f.ai_value
refs = {k: [f.ai_citation_id] for k, f in fields.items() if f.ai_citation_id and f.human_value is None}
lots = [x for x in (await s.execute(select(Lot).where(Lot.project_id == p.id))).scalars() if not x.is_default]
bidding = [x for x in lots if x.id in set(r.lot_ids)] if r.lot_ids else lots
ceiling, scope, note = None, "total", None
if len(bidding) > 1:
scope, note = "multi", "投了多个采购包,报价按包和限价比对还没有上线"
elif len(bidding) == 1:
ceiling, scope = bidding[0].ceiling_amount, "lot" if len(lots) > 1 else "total"
refs.pop("ceiling_total", None) # 包的限价没有单独的出处;说明里写明取自所投采购包
elif value("ceiling_total"):
ceiling = Decimal(str(value("ceiling_total")))
days, item = rules.required_validity(snapshot)
if item is not None:
refs["validity"] = [UUID(c) for c in snapshot[item].get("citation_ids") or []]
refs["deadline"] = refs.pop("deadline_at", [])
refs["ceiling"] = refs.pop("ceiling_total", [])
return rules.Facts(
name=value("name") or p.name,
code=value("code") or p.code,
purchaser=value("purchaser") or p.purchaser,
agency=value("agency") or p.agency,
deadline=p.deadline_at.astimezone(LOCAL_TZ).date() if p.deadline_at else None,
ceiling=ceiling,
ceiling_scope=scope,
ceiling_note=note,
validity_days=days,
validity_item=item,
refs=refs,
)
async def _write_rules(
s: AsyncSession,
ctx: TenantContext,
r: CheckRun,
hits: list[rules.Hit],
facts: rules.Facts,
by_doc: dict[UUID, SourceDocument],
) -> None:
"""规则问题:挂投标原文的句子;有依据的(限价、有效期要求、项目名称)再挂招标侧出处。"""
for h in hits:
bid_cites = []
for doc_id in dict.fromkeys(d.id for d, _ in h.evidence):
sents = [x for d, x in h.evidence if d.id == doc_id]
bid_cites += await citations.from_sentences(s, ctx, by_doc[doc_id], sents, "rule", [])
f = Finding(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=r.project_id,
check_run_id=r.id,
checklist_item_id=h.item,
rule_code=h.code,
match_key=f"rule:{h.code}:{h.target}",
type=h.type,
severity=h.severity,
title=h.title[:200],
explanation=h.explanation,
detector="rule",
detail=h.detail,
checks_passed=[],
presence="new",
)
s.add(f)
await s.flush()
for pos, cid in enumerate(facts.refs.get(h.basis or "", [])):
s.add(
FindingCitation(tenant_id=ctx.tenant_id, finding_id=f.id, citation_id=cid, side="tender", position=pos)
)
for pos, c in enumerate(bid_cites):
s.add(FindingCitation(tenant_id=ctx.tenant_id, finding_id=f.id, citation_id=c.id, side="bid", position=pos))
await s.flush()
async def _write(
s: AsyncSession,
ctx: TenantContext,
@@ -391,6 +493,7 @@ def _coverage(
tokens: Any,
skipped: dict[str, int],
modes: dict[str, response_mode.Mode],
rule_cov: dict[str, Any],
) -> dict[str, Any]:
"""检查覆盖范围:报告首页、查标页顶部都读这里(FR-M3-09、m3-plan §2.5)。"""
undetermined = sum(x.verdict == "undetermined" for x in results.values())
@@ -419,7 +522,9 @@ def _coverage(
implicit = sum(x.verdict in ("implicit_accepted", "implicit_review") for x in results.values())
if implicit:
checked.append(f"其中 {implicit} 条按招标文件「未列入偏离的视为接受」处理,没有复述不算问题")
not_checked.append("报价形式、形式要件、资质匹配:规则检查还没有上线")
checked.append(rule_cov["checked"])
not_checked += rule_cov["not_checked"]
not_checked.append("资质匹配(证书有效期、主体一致):还没有上线")
if not r.checklist_confirmed:
not_checked.append("清单未确认,检查结果以当前清单为准")
return {

View File

@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.check import rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.checklist import edit as checklist_edit
from bid_doctor.checklist import service as checklist
@@ -35,7 +36,9 @@ from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
CHECK_TASK = "check_project"
RULE_SET_VERSION = "2026.11-r0" # 第 1 步还没有规则;规则集有版本,检查批次记下当时用的版本(架构 §3.7)
RULE_SET_VERSION = (
"2026.11-r1" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r1:报价形式、形式要件(rules.py)
)
WAIVE_WINDOW = timedelta(hours=24)
ERROR_MESSAGES = {
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
@@ -307,7 +310,9 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
}
if item
else None,
"rule": {"code": f.rule_code, "description": ""} if f.rule_code and not item else None,
"rule": {"code": f.rule_code, "description": rules.LABELS.get(f.rule_code, "")}
if f.rule_code and not item
else None,
"detail": f.detail,
"tender_citations": [cites[x.citation_id] for x in ls if x.side == "tender" and x.citation_id in cites],
"bid_citations": [cites[x.citation_id] for x in ls if x.side == "bid" and x.citation_id in cites],

View File

@@ -59,6 +59,7 @@ class Sentence:
fmt_runs: list[tuple[int, int, str]] = field(default_factory=list) # [起, 止, 格式],格式取 b / i / u 的组合
location: str = "body"
open_end: bool = False # 写到页面末尾还没结束(没有句末标点、末行写满),可能接续到下一页
cell: list[float] | None = None # 在有框线的表格里:所在单元格 [x0, top, x1, bottom](查标按单元格分行)
@property
def marked(self) -> str:
@@ -83,6 +84,7 @@ class Sentence:
"format_runs": [list(r) for r in self.fmt_runs],
"location": self.location,
"open_end": self.open_end,
**({"cell": self.cell} if self.cell else {}),
}
@classmethod
@@ -96,6 +98,7 @@ class Sentence:
fmt_runs=[(a, b, f) for a, b, f in d["format_runs"]],
location=d["location"],
open_end=d.get("open_end", False),
cell=d.get("cell"),
)
@@ -150,6 +153,7 @@ Cell = tuple[float, float, float, float] # 单元格 (x0, top, x1, bottom)
class FragInfo:
right: float | None = None # 单元格的右边界:判断段落末行用它,不用页面右边距
cell_end: bool = False # 单元格的最后一个片段:句子在这里结束
cell: Cell | None = None
def _cell_of(c: dict[str, Any], cells: list[Cell]) -> int | None:
@@ -171,13 +175,13 @@ def _fragments(
for i, cell_chars in by_cell.items():
assert i is not None
x0, top, x1, _ = cells[i]
blocks.append(((top, x0), _line_fragments(cell_chars), FragInfo(right=x1)))
blocks.append(((top, x0), _line_fragments(cell_chars), FragInfo(right=x1, cell=cells[i])))
frags: list[list[dict[str, Any]]] = []
infos: list[FragInfo] = []
for _, fs, info in sorted(blocks, key=lambda b: b[0]):
for j, f in enumerate(fs):
frags.append(f)
infos.append(FragInfo(right=info.right, cell_end=j == len(fs) - 1) if info else FragInfo())
infos.append(FragInfo(right=info.right, cell_end=j == len(fs) - 1, cell=info.cell) if info else FragInfo())
return frags, infos
@@ -310,6 +314,7 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], infos: list
fmt_runs=runs,
location=location,
open_end=seq == tail_seq,
cell=[round(v, 2) for v in cell] if (cell := infos[g[0][0]].cell) and location == "body" else None,
)
)
return Page(

BIN
server/tests/fixtures/bid-mini-forms.pdf vendored Normal file

Binary file not shown.

View File

@@ -3,7 +3,9 @@
cd server && ../.venv/bin/python tests/fixtures/make_bid_fixtures.py # 需要 Gotenberg(:3109)
- bid-mini-v1.pdf:响应了资质证书和服务器内存,漏了交换机硬件 BFD → 1 个致命问题「未找到响应」;
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」。
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」;
- bid-mini-forms.pdf:v2 的内容 + 格式文件里的规则问题(大写和小写金额不一致、授权书没填投标人名称、
报价附加条件、另外给了一个优惠报价),措辞故意和评测集的开发词库不同。
"""
from __future__ import annotations
@@ -18,6 +20,7 @@ GOTENBERG = "http://127.0.0.1:3109/forms/chromium/convert/html"
HEAD = """<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: "Noto Serif CJK SC", "Noto Sans CJK SC", serif; font-size: 12pt; line-height: 1.8; margin: 0; }
h1 { font-size: 16pt; text-align: center; }
table { border-collapse: collapse; width: 100%; } td, th { border: 1pt solid #333; padding: 4pt; }
</style></head><body>"""
COMMON = [
@@ -41,6 +44,16 @@ def render(name: str, parts: list[str]) -> None:
print(name, len(r.content))
FORMS = [
"<p>五、法定代表人授权委托书。本人王某(姓名)系(投标人名称)的法定代表人,现授权李某为我方代理人。</p>",
"<p>六、开标一览表</p>",
"<table><tr><th>项目名称</th><th>投标总报价(元)</th><th>投标总报价(大写)</th></tr>"
"<tr><td>某市智慧政务系统集成项目</td><td>1,280,000.00</td><td>壹拾贰万捌仟元整</td></tr></table>",
"<p>让利后总价:1,250,000.00元。</p>",
"<p>七、本次报价未包含设备运输费用,运输费用由采购人另行承担。</p>",
]
if __name__ == "__main__":
render("bid-mini-v1.pdf", [*COMMON, *TAIL])
render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL])
render("bid-mini-forms.pdf", [*COMMON, BFD, *FORMS, *TAIL])

View File

@@ -272,3 +272,36 @@ async def test_response_modes_are_stored_editable_and_used(user: User) -> None:
(f,) = await findings(user, run["id"])
assert f["type"] == "manual_review" and f["severity"] == "hint"
assert run["counts"]["fatal"] == 0
async def test_rule_findings_are_written_with_citations_and_carried(user: User) -> None:
"""规则检查(报价形式、形式要件):问题挂规则编号和投标原文,复查时按 match_key 沿用处理状态。"""
project_id, tender_id = await project_with_tender(user)
await run_parse(user, tender_id)
bid = await upload_bid(user, project_id, "bid-mini-forms.pdf")
run = await run_check(user, project_id, [bid["id"]])
assert run["status"] == "succeeded", run
rule = {f["rule"]["code"]: f for f in await findings(user, run["id"]) if f["detector"] == "rule"}
assert set(rule) == {
"PRICE.CAPITAL_MISMATCH",
"PRICE.MULTIPLE_QUOTES",
"FORM.BLANK_FIELD",
"FORM.CONDITION_ADDED",
}
cap = rule["PRICE.CAPITAL_MISMATCH"]
assert cap["type"] == "price_form" and cap["severity"] == "important" and cap["checklist_item"] is None
assert cap["rule"]["description"] == "大写金额和小写金额核对"
(c,) = cap["bid_citations"] # 同一页的几句合成一条出处,各句分别画框
assert "壹拾贰万捌仟元整" in c["quote"] and "1,280,000.00" in c["quote"] and len(c["quads"]) >= 2
assert c["document_label"] == "技术分册"
# 「让利后总价」和投标总价不同:多半另报了一个价,但不是「备选方案」那种明确的两个报价,按重要
assert rule["PRICE.MULTIPLE_QUOTES"]["severity"] == "important" and rule["FORM.BLANK_FIELD"]["severity"] == "hint"
assert any("报价形式" in x for x in run["coverage"]["checked"])
assert run["coverage"]["rule_set_version"] == "2026.11-r1"
await user.post(f"/findings/{cap['id']}/resolve", json={"status": "ignored", "note": "大写栏已手工更正"})
again = await run_check(user, project_id, [bid["id"]])
cap2 = next(
f for f in await findings(user, again["id"]) if (f["rule"] or {}).get("code") == "PRICE.CAPITAL_MISMATCH"
)
assert cap2["presence"] == "carried" and cap2["status"] == "ignored"

View File

@@ -0,0 +1,259 @@
"""查标的规则检查(报价形式、形式要件):纯函数,投标文件用虚构的句子拼(rules.py,GUIDELINES §3.2)。
措辞故意和评测集的开发词库不同:规则按招投标文件的通用写法写,不按评测集的句子写。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import uuid4
from bid_doctor.check import match, rules
from bid_doctor.document.pipeline.text_layer import Sentence
def cell(seq: int, text: str, x: float, y: float, page: int = 1, w: float = 60) -> Sentence:
return Sentence(key=f"b:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[x, y, x + w, y + 12]])
def para(seq: int, text: str, page: int = 1) -> Sentence:
return cell(seq, text, 74, 30 + seq * 24, page, 440)
def doc(sentences: list[Sentence], header: list[Sentence] | None = None) -> match.BidDoc:
return match.BidDoc(uuid4(), 1, "商务分册", sentences, [], header or [])
def codes(hits: list[rules.Hit]) -> list[str]:
return sorted(h.code for h in hits)
def test_upper_amount_and_day_counts() -> None:
assert rules.upper_amount("壹佰贰拾贰万柒仟陆佰元整") == Decimal(1_227_600)
assert rules.upper_amount("壹亿零伍佰万元零伍角") == Decimal("105000000.5")
assert rules.upper_amount("拾万元整") == Decimal(100_000)
assert rules.validity_days("投标有效期为开标之日起一百二十(120)天。") == [120]
assert rules.validity_days("本投标文件自投标截止之日起三个月内有效。") == [90]
assert rules.validity_days("投标有效期内中标的,须在30日内签订合同。") == [] # 「有效期内」后面的天数不是有效期
def opening_table(lower: str, upper: str) -> list[Sentence]:
return [
cell(1, "项目名称", 80, 100),
cell(2, "投标总价(小写,元)", 220, 100),
cell(3, "投标总价(大写)", 380, 100),
cell(4, "某市数据中心扩容项目", 80, 124),
cell(5, lower, 220, 124),
cell(6, upper, 380, 124),
]
def test_capital_mismatch_and_over_ceiling() -> None:
d = doc(opening_table("2,050,000.00", "贰佰万元整"))
hits, _ = rules.run(rules.Facts(ceiling=Decimal(2_000_000)), [d], [])
assert codes(hits) == ["PRICE.CAPITAL_MISMATCH", "PRICE.OVER_CEILING_TOTAL"]
over = next(h for h in hits if h.code == "PRICE.OVER_CEILING_TOTAL")
assert over.severity == "fatal" and over.basis == "ceiling" and over.evidence[0][1].text == "2,050,000.00"
assert "建议" not in over.explanation and "应改为" not in over.explanation # 红线 6:不写数字建议
same = doc(opening_table("2,050,000.00", "贰佰零伍万元整"))
assert rules.run(rules.Facts(ceiling=Decimal(3_000_000)), [same], [])[0] == []
def item_table(rows: list[tuple[str, str, str, str]], total: str, page: int = 1) -> list[Sentence]:
out = [
cell(1, "序号", 80, 100, page, 20),
cell(2, "货物名称", 110, 100, page, 100),
cell(3, "数量", 260, 100, page, 30),
cell(4, "单价(元)", 320, 100, page),
cell(5, "合价(元)", 420, 100, page),
]
for i, (name, qty, unit, line) in enumerate(rows):
y, n = 124 + i * 22, 10 + i * 5
out += [
cell(n, str(i + 1), 82, y, page, 8),
cell(n + 1, name, 110, y, page, 100),
cell(n + 2, qty, 272, y, page, 8), # 窄数字居中,和表头「数量」不重叠
cell(n + 3, unit, 320, y, page),
cell(n + 4, line, 420, y, page),
]
out.append(cell(99, f"合计:{total}元", 80, 124 + len(rows) * 22 + 6, page, 300))
return out
def test_item_table_arithmetic() -> None:
ok = item_table(
[("服务器", "3", "100,000.33", "300,001.00"), ("存储", "1", "50,000.00", "50,000.00")], "350,001.00"
)
assert rules.run(rules.Facts(), [doc(ok)], [])[0] == [] # 单价舍入到分,合价差几分钱不算错
bad = item_table(
[("服务器", "3", "100,000.00", "310,000.00"), ("存储", "1", "50,000.00", "50,000.00")], "350,000.00"
)
(h,) = rules.run(rules.Facts(), [doc(bad)], [])[0]
assert h.code == "PRICE.SUM_MISMATCH" and "服务器" in h.explanation and "合计行" in h.explanation
assert "310,000.00" in [s.text for _, s in h.evidence]
def test_foreign_project_in_header_and_filled_fields() -> None:
facts = rules.Facts(name="某市数据中心扩容项目", code="ZC-2026-0815-A", purchaser="某市大数据局")
header = [
Sentence(
key=f"b:p{p}:s0",
page_no=p,
seq=0,
text="某县人民医院信息化改造工程",
quads=[[0, 0, 1, 1]],
location="header_footer",
)
for p in (1, 2)
]
body = [
para(1, "致:某市大数据局(采购人名称)"),
para(2, "参加ZC-2026-0815-A(项目编号)某市数据中心扩容项目(项目名称)的投标。"),
para(3, "我方承诺参加ZC-2025-0311-B(项目编号)的投标活动。"),
para(4, "类似业绩:项目名称:某省政务云平台运维服务项目,合同金额200万元。"), # 业绩不在填写位里,不查
]
hits, _ = rules.run(facts, [doc(body, header)], [])
assert codes(hits) == ["FORM.FOREIGN_PROJECT", "FORM.FOREIGN_PROJECT"]
head = next(h for h in hits if h.target.startswith("header:"))
assert head.detail["pages"] == [1, 2] and head.basis == "name"
assert any("ZC-2025-0311-B" in h.explanation for h in hits)
def test_blank_fields_and_kept_annotations() -> None:
d = doc(
[
para(1, "投标人名称(盖单位章):某某科技有限公司"), # 括注保留、名称已填:不算
para(2, "本授权书于年月日签署生效。"),
para(3, "被授权人:____(签字)"),
para(4, "法定代表人身份证号:/"), # 划除:不算
]
)
hits, _ = rules.run(rules.Facts(), [d], [])
assert [h.evidence[0][1].seq for h in hits] == [2, 3] and {h.severity for h in hits} == {"hint"}
def test_sign_dates_validity_conditions_and_extra_quotes() -> None:
facts = rules.Facts(deadline=date(2026, 12, 1), validity_days=90)
d = doc(
[
para(1, "签署日期:2026年12月3日"),
para(2, "日期:2026年11月31日"),
para(3, "证书发证日期:2027年1月1日"), # 不是落款日期
para(4, "本投标有效期为自投标截止之日起六十日。"),
para(5, "供货数量按实际需求结算,安装调试费用由采购人负责。"),
para(6, "培训费用已包含在报价中,不另行收费。"), # 承诺,不是附加条件
para(7, "第二套报价方案总价:1,880,000.00元"),
para(8, "我方不提交备选方案报价。"),
]
)
hits, _ = rules.run(facts, [d], [])
got = {(h.code, h.evidence[0][1].seq) for h in hits}
assert got == {
("FORM.DATE_AFTER_DEADLINE", 1),
("FORM.DATE_AFTER_DEADLINE", 2),
("FORM.VALIDITY_SHORT", 4),
("FORM.CONDITION_ADDED", 5),
("PRICE.MULTIPLE_QUOTES", 7),
}
bad_date = next(h for h in hits if h.evidence[0][1].seq == 2)
assert bad_date.title == "落款日期写法有误"
def test_response_table_negative_and_dedupe() -> None:
rows = [
cell(1, "实质性要求响应表", 74, 76, w=200),
cell(2, "序号", 80, 100, w=20),
cell(3, "实质性要求", 110, 100, w=150),
cell(4, "响应情况", 300, 100),
cell(5, "1", 82, 124, w=8),
cell(6, "“交货期”的响应情况", 110, 124, w=150),
cell(7, "完全响应", 300, 124),
cell(8, "2", 82, 146, w=8),
cell(9, "“付款方式”的响应情况", 110, 146, w=150),
cell(10, "部分满足", 300, 146),
cell(11, "3", 82, 168, w=8),
cell(12, "“质保期”的响应情况", 110, 168, w=150),
]
d = doc(rows)
hits, _ = rules.run(rules.Facts(), [d], [])
assert [(h.title, h.severity) for h in hits] == [
("响应表里写了「部分满足」", "fatal"),
("响应表里有一行没有填响应情况", "important"), # 「空着」可能是版面没认准:不给致命
]
covered = {("商务分册", 1, "部分满足")} # 响应匹配已经按清单项报了这一句
assert [h.title for h in rules.drop_covered(hits, covered)] == ["响应表里有一行没有填响应情况"]
def test_missing_facts_go_to_coverage() -> None:
_, cov = rules.run(rules.Facts(), [doc([para(1, "我方承诺按招标文件要求提供服务。")])], [])
text = ";".join(cov["not_checked"])
for want in ("没有认出开标一览表", "最高限价", "投标截止时间", "投标有效期", "项目名称"):
assert want in text
def boxed(seq: int, text: str, cell: list[float], dy: float = 0) -> Sentence:
"""有框线表格里的句子:文字框可以比单元格上沿低(垂直居中),分行按单元格。"""
x0, top, x1, _ = cell
s = cell_sentence = Sentence(
key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, top + 2 + dy, x1 - 2, top + 14 + dy]]
)
cell_sentence.cell = cell
return s
def test_real_layout_negatives() -> None:
"""真实投标文件常见、但不该报的写法(Fable 审读提出的反例)。"""
# 分组小计不算进明细;金额栏叫「投标报价」的分项表不是开标一览表,几行金额不算「多个总价」
rows = item_table([("服务器", "2", "10,000.00", "20,000.00"), ("交换机", "1", "5,000.00", "5,000.00")], "25,000.00")
rows.insert(-1, cell(90, "硬件小计", 110, 175, w=100))
rows.insert(-1, cell(91, "25,000.00", 420, 175))
multi = [
cell(1, "年度", 80, 300, page=2),
cell(2, "投标报价(元)", 220, 300, page=2),
cell(3, "第一年", 80, 324, page=2),
cell(4, "100,000.00", 220, 324, page=2),
cell(5, "第二年", 80, 348, page=2),
cell(6, "120,000.00", 220, 348, page=2),
]
# 响应表:短格垂直居中,文字比编号低半行,但在同一个单元格行里
resp = [
boxed(20, "序号", [80, 400, 100, 420]),
boxed(21, "符合性审查要求", [100, 400, 300, 420]),
boxed(22, "响应情况", [300, 400, 380, 420]),
boxed(23, "1", [80, 420, 100, 460]),
boxed(24, "投标人须承诺按期交货,交货期不超过合同签订后三十日", [100, 420, 300, 460]),
boxed(25, "完全响应", [300, 420, 380, 460], dy=14),
]
header = [
Sentence(
key="b:p1:s0", page_no=1, seq=0, text="第三章 技术服务方案", quads=[[0, 0, 1, 1]], location="header_footer"
)
]
body = [
para(30, "致:贵单位"),
para(31, "附件:原厂报价单。本报价有效期30天,过期另行报价。", page=3),
para(32, "优惠后报价:25,000.00元", page=3),
para(33, "日 期:二〇二六年十一月二十日", page=3),
]
facts = rules.Facts(
name="某市数据中心扩容项目", purchaser="某市大数据局", deadline=date(2026, 12, 1), validity_days=60
)
hits, _ = rules.run(facts, [doc([*rows, *multi, *resp, *body], header)], [])
assert [(h.code, h.severity) for h in hits] == [("FORM.CONDITION_ADDED", "important")] # 「过期另行报价」照报
def test_validity_in_letter_table_and_chinese_dates() -> None:
page = [
para(1, "投标函"),
cell(2, "投标有效期", 80, 200),
cell(3, "四十五天", 220, 200),
para(4, "落款日期:二〇二六年十二月三日"),
]
hits, _ = rules.run(rules.Facts(deadline=date(2026, 12, 1), validity_days=60), [doc(page)], [])
assert {(h.code, h.severity) for h in hits} == {
("FORM.VALIDITY_SHORT", "fatal"),
("FORM.DATE_AFTER_DEADLINE", "hint"),
}