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:
@@ -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。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 步冻结代码后只跑一次。
|
||||
|
||||
@@ -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 步落地记录**(和本文设计不一致、或留到后面的,记在这里):
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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']}),"
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
|
||||
1083
server/src/bid_doctor/check/rules.py
Normal file
1083
server/src/bid_doctor/check/rules.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
BIN
server/tests/fixtures/bid-mini-forms.pdf
vendored
Normal file
Binary file not shown.
15
server/tests/fixtures/make_bid_fixtures.py
vendored
15
server/tests/fixtures/make_bid_fixtures.py
vendored
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
|
||||
259
server/tests/test_check_rules.py
Normal file
259
server/tests/test_check_rules.py
Normal 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"),
|
||||
}
|
||||
Reference in New Issue
Block a user