Files
bid-doctor/server/src/bid_doctor/check/rules.py
YANG JIANKUAN df3ebbb6a1 feat(check): 偏离栏直读、响应错位判断;复查复用和硬预算(C2)
- deviation.py:模型判已响应的条目,证据所在表格行的偏离格写着「偏离 / 部分满足」等
  就改判负偏离;照抄要求格、响应格写的是别的内容的改判「响应可能对不上」
- reuse.py:按「提示词 + 要求 + 候选句原文」算缓存键,存模型原始结论;复查时内容没变、
  证据找得回的条目直接沿用,不调模型
- 每次检查送给模型的字数有上限,超出的条目列为请人工核对,写进覆盖范围
- 开发集 17 份:召回 61% → 73%,负偏离未说明 64% → 90%,规矩投标文件问题数不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:47:12 +08:00

1110 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""规则检查:报价形式和形式要件(m3-plan §2.2,评测规范 GUIDELINES §3.2 的 P 类问题),不调模型。
- 投标侧结构只认最常见的几种:开标一览表(总报价那一栏)、分项报价表(数量 / 单价 / 总价三栏,可以跨页)、
响应表(有「响应情况 / 偏离情况」一栏)、落款日期、格式文件里的填写位、页眉;
- 表格分行:有框线的表格按单元格的上沿(文字层带的单元格框),其余按句子上下重叠;
- 每条结论都挂投标原文的句子(红线 2);说明只写读到的数字和依据,不写「应改为多少」(红线 6);
- 致命级只给版面上把握大的情形(超限价、备选方案、投标函里的有效期、符合性响应表里写了不响应);
认不准的降为重要或提示,宁可让人核对,不让规矩的投标文件挂致命问题;
- 认不出结构、缺项目侧的值(限价、截止时间、有效期要求)时不报问题,写进检查覆盖范围(m3-plan §2.5);
- 写法按招投标文件的通用格式归纳,不按评测集的措辞写(评测集的开发 / 留出词库分开,见 check-eval.md)。
"""
from __future__ import annotations
import hashlib
import re
from collections import Counter
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import UUID
from bid_doctor.check import deviation
from bid_doctor.check.match import BidDoc
from bid_doctor.document.pipeline import layout
from bid_doctor.document.pipeline.layout import column as _col
from bid_doctor.document.pipeline.layout import rows as rows_of
from bid_doctor.document.pipeline.layout import span as _span
from bid_doctor.document.pipeline.text_layer import Sentence, norm
LABELS = {
"PRICE.OVER_CEILING_TOTAL": "报价和最高限价比对",
"PRICE.OVER_CEILING_LOT": "报价和所投采购包的最高限价比对",
"PRICE.OVER_CEILING_ITEM": "分项报价和分项 / 单价上限比对",
"PRICE.CAPITAL_MISMATCH": "大写金额和小写金额核对",
"PRICE.SUM_MISMATCH": "分项报价合计核对",
"PRICE.TOTAL_INCONSISTENT": "投标总价前后一致",
"PRICE.MULTIPLE_QUOTES": "只能有一个报价",
"FORM.FOREIGN_PROJECT": "别的项目名称、编号、采购人",
"FORM.BLANK_FIELD": "格式文件里没填的地方",
"FORM.DATE_AFTER_DEADLINE": "落款日期",
"FORM.VALIDITY_SHORT": "投标有效期",
"FORM.CONDITION_ADDED": "附加条件",
"FORM.RESPONSE_NEGATIVE": "响应表里的未响应、负偏离",
}
RULES_NOT_YET = (
"分项报价表和采购需求清单逐项对照、格式文件固定文字被改、"
"授权代表和签字人一致、声明函填报、签章有无、低价说明、跨页的响应表"
)
@dataclass(frozen=True, slots=True)
class Facts:
"""项目侧(招标文件、项目基本信息)的已知值。refs:依据 → 招标侧出处编号(产品里是 citation id,评测里为空)。"""
name: str | None = None
code: str | None = None
purchaser: str | None = None
agency: str | None = None
deadline: date | None = None
ceiling: Decimal | None = None
ceiling_scope: str = "total" # total:项目限价;lot:所投采购包的限价;multi:投了几个包
ceiling_note: str | None = None # 没有可比的限价时的原因(写进覆盖范围)
validity_days: int | None = None
validity_item: UUID | None = None
caps: list[ItemCap] = field(default_factory=list["ItemCap"]) # 所投采购包的分项 / 单价上限(「报价」类清单项)
refs: dict[str, list[UUID]] = field(default_factory=dict[str, list[UUID]])
@dataclass(frozen=True, slots=True)
class ItemCap:
"""分项上限(checklist/price.py 抽出的「报价」类清单项):per = unit 按单价比,item 按分项总价比。"""
name: str
cap: Decimal
per: str
item: UUID | None = None
def caps_of(items: dict[UUID, dict[str, Any]]) -> list[ItemCap]:
out = []
for item_id, p in items.items():
spec = (p.get("spec") or {}).get("price") or {}
if spec.get("kind") in ("unit_cap", "item_cap") and spec.get("name") and spec.get("cap"):
try:
cap = Decimal(str(spec["cap"]))
except InvalidOperation:
continue
out.append(ItemCap(str(spec["name"]), cap, "unit" if spec["kind"] == "unit_cap" else "item", item_id))
return out
@dataclass(slots=True)
class Hit:
code: str
type: str # price_form / formality / negative_deviation
severity: str
title: str
explanation: str
evidence: list[tuple[BidDoc, Sentence]]
target: str # 复查对齐用:match_key = rule:<code>:<target>
basis: str | None = None # Facts.refs 的键:招标侧出处
item: UUID | None = None # 对应的清单项(有的话)
detail: dict[str, Any] = field(default_factory=dict[str, Any])
def target(text: str) -> str:
"""复查对齐用的目标:原文前 30 字 + 全文摘要(前缀相同的两句不会互相吞掉,原文没改就能对上)。"""
t = norm(text)
return f"{t[:30]}#{hashlib.sha256(t.encode()).hexdigest()[:8]}"
# ---------- 金额、数字 ----------
MONEY = re.compile(r"(?<![\d.,])(\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?|\d+\.\d{1,2}|\d+)(?![\d,])\s*(万元|元)?")
UPPER = re.compile(
r"[壹贰叁肆伍陆柒捌玖拾][零壹贰叁肆伍陆柒捌玖拾佰仟万亿]*[元圆]"
r"(?:[零壹贰叁肆伍陆柒捌玖]角)?(?:[零壹贰叁肆伍陆柒捌玖]分)?[整正]?"
)
DIGIT = {c: i for i, c in enumerate("零壹贰叁肆伍陆柒捌玖")} | {"〇": 0}
SMALL = {"拾": 10, "佰": 100, "仟": 1000}
CN = {c: i for i, c in enumerate("零一二三四五六七八九")} | {"两": 2, "〇": 0}
def upper_amount(s: str) -> Decimal | None:
"""大写金额 → 数值(元)。写法不成形的返回 None。"""
total, section, num, frac = Decimal(0), 0, 0, Decimal(0)
for ch in s:
if ch in DIGIT:
num = DIGIT[ch]
elif ch in SMALL:
section += (num or 1) * SMALL[ch]
num = 0
elif ch == "万":
total += Decimal((section + num) * 10_000)
section = num = 0
elif ch == "亿":
total = (total + section + num) * 100_000_000
section = num = 0
elif ch in "元圆":
total += section + num
section = num = 0
elif ch == "角":
frac += Decimal(num) / 10
num = 0
elif ch == "分":
frac += Decimal(num) / 100
num = 0
if section or num:
return None
return total + frac
def money(text: str, *, plain: bool = False) -> list[Decimal]:
"""文本里像金额的数:带千分位、两位小数或「元 / 万元」的(plain=True 时整数也算,表格的金额栏用)。"""
out = []
for m in MONEY.finditer(text):
raw, unit = m.group(1), m.group(2)
if not (plain or unit or "," in raw or re.search(r"\.\d\d$", raw)):
continue
try:
v = Decimal(raw.replace(",", ""))
except InvalidOperation:
continue
out.append(v * 10_000 if unit == "万元" else v)
return out
def cn_int(s: str) -> int | None:
"""「六十」「一百二十」「90」→ 整数。"""
if s.isdigit():
return int(s)
total, num = 0, 0
for ch in s:
if ch in CN:
num = CN[ch]
elif ch == "十":
total += (num or 1) * 10
num = 0
elif ch == "百":
total += (num or 1) * 100
num = 0
else:
return None
return total + num or None
def quote(text: str, n: int = 60) -> str:
"""说明里引用的原文:截断,去掉句末标点(后面接自己的句号)。"""
return text[:n].rstrip("。;;,, ")
def yuan(v: Decimal) -> str:
return f"{v:,.2f} 元"
# ---------- 版面:页、行、单元格(document/pipeline/layout.py) ----------
def pages_of(doc: BidDoc) -> dict[int, list[Sentence]]:
return layout.pages(doc.sentences)
def row_index(doc: BidDoc) -> dict[int, list[Sentence]]:
"""句子 → 它所在的行(按句子对象)。"""
return {id(c): row for page in pages_of(doc).values() for row in rows_of(page) for c in row}
# ---------- 分项报价表、开标一览表 ----------
@dataclass(slots=True)
class ItemRow:
cells: list[Sentence]
qty: Decimal | None
unit_price: Decimal | None
line: Decimal | None
ambiguous: bool = False # 同一栏读到两个数(无框线表格里纵向合并的格子把几行并成了一行):不核对
@dataclass(slots=True)
class PriceTable:
doc: BidDoc
rows: list[ItemRow]
total: tuple[Sentence, Decimal] | None # 合计行
@dataclass(frozen=True, slots=True)
class Total:
sentence: Sentence
value: Decimal
scaled: bool # 按表头的「万元」折算过
HEAD_QTY = re.compile(r"^数量")
HEAD_UNIT = re.compile(r"单价")
HEAD_LINE = re.compile(r"(合价|总价|小计|金额)")
TOTAL_ROW = re.compile(r"^(合计|总计|总价|报价合计|投标总价)")
SUBTOTAL = re.compile(r"(小计|合计|总计)")
TOTAL_HEAD = re.compile(r"(投标总?报价|总报价|投标总价|报价合计|响应报价|磋商报价|投标报价)")
def _header(row: list[Sentence]) -> dict[str, tuple[float, float]] | None:
cols: dict[str, tuple[float, float]] = {}
for n, c in enumerate(row):
t = norm(c.text)
if HEAD_QTY.search(t) and "qty" not in cols:
cols["qty"] = _span(c)
elif HEAD_UNIT.search(t) and "unit" not in cols:
cols["unit"] = _span(c)
elif HEAD_LINE.search(t) and "line" not in cols:
cols["line"] = _span(c)
else:
cols[f"c{n}"] = _span(c) # 序号、名称、单位等栏:也要参与归栏
return cols if {"qty", "unit", "line"} <= cols.keys() else None
def _scale(row: list[Sentence]) -> int:
return 10_000 if any("万元" in c.text for c in row) else 1
def price_tables(doc: BidDoc) -> list[PriceTable]:
"""分项报价表:表头有数量、单价、总价三栏;表体逐行读到合计行或表结束,可以接续到下一页
(下一页重复表头的,接着算同一张表)。「硬件小计」这类分组小计不算进明细。"""
tables: list[PriceTable] = []
cur: tuple[PriceTable, dict[str, tuple[float, float]], int] | None = None
for _, page in sorted(pages_of(doc).items()):
for i, row in enumerate(rows_of(page)):
cols = _header(row)
if cols:
if cur is not None and i <= 1 and cur[0].rows and cur[0].total is None:
cur = (cur[0], cols, _scale(row)) # 跨页重复的表头
else:
cur = (PriceTable(doc, [], None), cols, _scale(row))
tables.append(cur[0])
continue
if cur is None:
continue
table, cols, scale = cur
text = norm("".join(c.text for c in row))
words = norm("".join(c.text for c in row if not money(c.text, plain=True)))
if TOTAL_ROW.search(text):
amounts = [
(c, v * scale) for c in row if _col(c, cols) == "line" for v in money(c.text, plain=True)[:1]
]
amounts = amounts or [(c, v) for c in row for v in money(c.text)[:1]]
if amounts:
table.total = amounts[0]
cur = None
continue
if SUBTOTAL.search(words):
continue # 分组小计
found: dict[str, list[Decimal]] = {}
for c in row:
name = _col(c, cols)
nums = money(c.text, plain=True)
if name and nums and len(norm(c.text)) <= 20:
found.setdefault(name, []).append(nums[0] * (scale if name != "qty" else 1))
if "unit" not in found and "line" not in found:
if table.rows and all(_span(c)[1] <= cols["qty"][0] for c in row):
continue # 名称栏折行的续行
cur = None # 表格结束(下一节的标题、正文)
continue
ambiguous = any(len(found.get(k, [])) > 1 for k in ("qty", "unit", "line"))
table.rows.append(
ItemRow(
row,
(found.get("qty") or [None])[0],
(found.get("unit") or [None])[0],
(found.get("line") or [None])[0],
ambiguous,
)
)
return [t for t in tables if t.rows]
def bid_totals(doc: BidDoc) -> list[Total]:
"""开标一览表(报价一览表)里写的投标总价:表头写「投标总报价 / 投标报价」(不是大写栏)的那一栏,
下面恰好只有一个金额才算(几个金额的是分项表、多年度报价表,不是总价);以及「投标总价:xxx 元」这样的句子。"""
out: list[Total] = []
for _, page in sorted(pages_of(doc).items()):
rows = rows_of(page)
for i, row in enumerate(rows):
heads = [
n
for n, c in enumerate(row)
if TOTAL_HEAD.search(norm(c.text)) and "大写" not in c.text and len(c.text) <= 30
]
if heads and len(row) > 1 and not _header(row):
cols = {f"c{n}": _span(c) for n, c in enumerate(row)}
want = {f"c{n}" for n in heads}
scale = _scale([row[n] for n in heads])
vals: list[tuple[Sentence, Decimal]] = []
for nxt in rows[i + 1 : i + 6]:
if _header(nxt) or TOTAL_HEAD.search(norm("".join(c.text for c in nxt))):
break
for c in nxt:
if _col(c, cols) in want and not UPPER.search(c.text):
vals += [(c, v * scale) for v in money(c.text, plain=True)[:1] if v * scale >= 100]
if len(vals) == 1:
out.append(Total(vals[0][0], vals[0][1], scale != 1))
continue
for c in row:
m = re.search(r"(投标总?报价|总报价|投标总价|报价合计)(?:[((]?小写[))]?)?[::]\s*(.+)", norm(c.text))
if m:
out += [Total(c, v, False) for v in money(m.group(2))[:1]]
return out
# ---------- 检查 ----------
def check_prices(
facts: Facts, docs: list[BidDoc], tables: list[PriceTable], totals: list[Total], hits: list[Hit], notes: list[str]
) -> None:
by_doc = {id(s): d for d in docs for s in d.sentences}
if not tables and not totals:
notes.append("没有认出开标一览表和分项报价表,报价金额没有核对")
values = sorted({t.value for t in totals})
# 分项表:数量 × 单价 = 总价;各项总价相加 = 合计行(或开标一览表唯一的总价)
for n, t in enumerate(tables, start=1):
wrong: list[str] = []
ev: list[tuple[BidDoc, Sentence]] = []
for r in t.rows:
if r.ambiguous or r.qty is None or r.unit_price is None or r.line is None:
continue
tol = r.qty * Decimal("0.005") + Decimal("0.01") # 单价四舍五入到分,总价按未舍入的单价算
diff = r.qty * r.unit_price - r.line
if abs(diff) > tol:
name = next((c.text for c in r.cells if not money(c.text, plain=True)), "")
wrong.append(
f"「{name[:20]}」数量 {r.qty:,} × 单价 {yuan(r.unit_price)},"
f"和这一行的总价 {yuan(r.line)} 相差 {yuan(abs(diff))}"
)
ev += [(t.doc, c) for c in r.cells if money(c.text, plain=True)]
lines = [r.line for r in t.rows if r.line is not None]
target_value = t.total[1] if t.total else (values[0] if len(tables) == 1 and len(values) == 1 else None)
if lines and target_value is not None and not any(r.ambiguous for r in t.rows):
s = sum(lines, Decimal(0))
if abs(s - target_value) > Decimal("0.01") * len(lines) + Decimal("0.005"):
where = "合计行" if t.total else "开标一览表的投标总价"
wrong.append(f"各项总价相加是 {yuan(s)},{where}写的是 {yuan(target_value)}")
ref = t.total[0] if t.total else next(x.sentence for x in totals if x.value == target_value)
ev.append((by_doc[id(ref)], ref))
if wrong:
hits.append(
Hit(
"PRICE.SUM_MISMATCH",
"price_form",
"important",
"分项报价的数字对不上",
";".join(wrong[:4]) + (";……" if len(wrong) > 4 else "") + "。请核对分项报价表和开标一览表。",
ev[:8],
f"table{n}",
detail={"mismatches": wrong},
)
)
# 分项合计和开标一览表的总价不一致(分项表没有合计行时上面已经比过)
for t in tables:
if t.total and values and all(abs(v - t.total[1]) > Decimal("0.005") for v in values):
first = totals[0]
hits.append(
Hit(
"PRICE.SUM_MISMATCH",
"price_form",
"important",
"开标一览表的总价和分项报价合计不一致",
f"开标一览表写的是 {yuan(first.value)},分项报价表合计是 {yuan(t.total[1])}。",
[(by_doc[id(first.sentence)], first.sentence), (t.doc, t.total[0])],
"summary",
)
)
break
# 不同位置写的投标总价不一致(投标函和开标一览表、两个分册):不判断哪个是「另一个报价」,请人核对
if len(values) > 1 and facts.ceiling_scope != "multi":
hits.append(
Hit(
"PRICE.TOTAL_INCONSISTENT",
"price_form",
"important",
"投标总价前后写的不一致",
"投标文件不同位置写的投标总价是 " + "、".join(yuan(v) for v in values) + ",请核对。",
[(by_doc[id(x.sentence)], x.sentence) for x in totals][:4],
"totals",
)
)
# 限价
if facts.ceiling is None:
notes.append(facts.ceiling_note or "项目信息里没有最高限价,报价没有和限价比对")
elif totals:
over = [x for x in totals if x.value > facts.ceiling]
if over:
x = max(over, key=lambda t: t.value)
lot = facts.ceiling_scope == "lot"
hits.append(
Hit(
"PRICE.OVER_CEILING_LOT" if lot else "PRICE.OVER_CEILING_TOTAL",
"price_form",
"fatal",
"投标报价超过最高限价",
f"开标一览表的投标总价是 {yuan(x.value)}{'(按表头的「万元」折算)' if x.scaled else ''},"
f"招标文件的{'所投采购包' if lot else ''}最高限价是 {yuan(facts.ceiling)}。"
"报价超过最高限价通常会导致投标无效,请核对。",
[(by_doc[id(x.sentence)], x.sentence)],
"total",
basis="ceiling",
detail={"bid_total": str(x.value), "ceiling": str(facts.ceiling), "scaled": x.scaled},
)
)
# 大写和小写金额
for d in docs:
page = pages_of(d)
rows = row_index(d)
for s in d.sentences:
for m in UPPER.finditer(s.text):
v = upper_amount(m.group(0))
if v is None:
continue
near = _lower_near(s, m, page.get(s.page_no, []), rows)
if not near or any(v in (x, x * 10_000) for x, _ in near):
continue
x, src = near[0]
hits.append(
Hit(
"PRICE.CAPITAL_MISMATCH",
"price_form",
"important",
"大写金额和小写金额不一致",
f"大写「{m.group(0)}」折合 {yuan(v)},旁边的小写金额是 {yuan(x)}。",
[(d, s), *([(d, src)] if src is not s else [])],
target(m.group(0) + s.text),
detail={"upper": m.group(0), "upper_value": str(v), "lower": str(x)},
)
)
PAREN = re.compile(r"[((][^))]*[))]")
def _match_cap(name: str, caps: list[ItemCap]) -> ItemCap | None:
"""分项名称对上哪条上限。投标文件的分项名称通常照抄采购需求,所以要求很严:完全相同(或去掉括注后相同)优先;
否则字对重合度不低于 0.8,或一方包含另一方且长度比不低于 0.8。「服务器」不会对上「服务器机柜」。"""
n = norm(name)
for c in caps:
if norm(c.name) == n or PAREN.sub("", norm(c.name)) == PAREN.sub("", n):
return c
best, score = None, 0.0
for c in caps:
k = norm(c.name)
x, y = _bigrams(k), _bigrams(n)
dice = 2 * len(x & y) / (len(x) + len(y)) if x and y else 0.0
contained = (k in n or n in k) and min(len(k), len(n)) / max(len(k), len(n)) >= 0.8
s = max(dice, 0.8 if contained else 0.0)
if s >= 0.8 and s > score:
best, score = c, s
return best
def check_item_caps(facts: Facts, tables: list[PriceTable], hits: list[Hit], notes: list[str]) -> None:
"""分项 / 单价上限(第三层限价,FR-M3-06):分项报价表里同名分项的单价(或分项总价)超过招标文件写的上限。"""
if not facts.caps:
return
if not tables:
notes.append(f"招标文件写了分项 / 单价上限 {len(facts.caps)} 条,投标文件里没认出分项报价表,没有比对")
return
matched = 0
for t in tables:
for r in t.rows:
name = next((c.text for c in r.cells if not money(c.text, plain=True) and len(norm(c.text)) >= 2), "")
cap = _match_cap(name, facts.caps) if name else None
value = None if cap is None else r.unit_price if cap.per == "unit" else r.line
if cap is None or value is None or r.ambiguous:
continue
matched += 1
if value <= cap.cap:
continue
what = "单价" if cap.per == "unit" else "分项总价"
hits.append(
Hit(
"PRICE.OVER_CEILING_ITEM",
"price_form",
"fatal",
f"「{name[:20]}」的{what}超过上限",
f"分项报价表里「{name[:30]}」的{what}是 {yuan(value)},招标文件写的{what}上限是 {yuan(cap.cap)}。"
"超过分项或单价上限通常会导致投标无效,请核对。",
[(t.doc, c) for c in r.cells if money(c.text, plain=True) or c.text == name],
target(name),
basis=f"cap:{cap.item}",
item=cap.item,
detail={"value": str(value), "cap": str(cap.cap), "per": cap.per},
)
)
if matched < len(facts.caps):
notes.append(f"分项 / 单价上限 {len(facts.caps)} 条,在分项报价表里对上名称的 {matched} 条,其余没有比对")
LOWER_MARK = re.compile(r"小写|[¥¥]|报价|总价|合计|金额|价款")
def _lower_near(
s: Sentence, m: re.Match[str], page: list[Sentence], rows: dict[int, list[Sentence]]
) -> list[tuple[Decimal, Sentence]]:
"""大写金额旁边的小写金额,按把握排序:同一句(「合计:1,000.00 元(壹仟元整)」)→ 同一行的单元格 →
同一页前后两句里写着「小写 / ¥ / 报价 / 合计」的。任何一处相等就算一致(调用方判断)。"""
out = [(v, s) for v in money(s.text[: m.start()] + s.text[m.end() :])]
row = rows.get(id(s), [])
out += [(v, c) for c in row if c is not s for v in money(c.text)]
i = next((n for n, x in enumerate(page) if x is s), -1)
for off in (1, 2):
for j in (i - off, i + off):
near = 0 <= j < len(page) and i >= 0 and not any(page[j] is c for c in row)
if near and LOWER_MARK.search(page[j].text):
out += [(v, page[j]) for v in money(page[j].text)]
return out
ALT = re.compile(
r"(备选|替代|选择性|可选)(方案)?(报价|价格|总价)|优惠后?的?(报价|价格|总价|价)|折扣后?的?(报价|价格|总价|价)|"
r"方案[一二三四五1-5][^,。;]{0,6}(报价|总价|价格)|第[二三][种套个份]?(报价|方案)|(让利|下浮|降价)后?的?(报价|价格|总价)"
)
ALT_FATAL = re.compile(r"备选|替代|选择性|可选|方案[二三四五2-5]|第[二三]")
NEG = re.compile(r"不|无|未|拒绝")
RESP_PREFIX = re.compile(r"^(完全响应|已响应|响应|满足|无偏离|正偏离|同意|接受)[,,::。、\s]*")
NUMBERING = re.compile(r"^([((]?[0-9一二三四五六七八九十]{1,3}[)).、.]|[0-9]+(\.[0-9]+)+)\s*")
class Tender:
"""招标文件全文(去空白):投标文件照抄的招标原文(逐条响应表里「满足,」后面复述的要求)不当作投标人自己的话。"""
def __init__(self, sentences: list[Sentence]) -> None:
self.text = "".join(norm(s.text) for s in sentences)
def restated(self, text: str) -> bool:
core = NUMBERING.sub("", RESP_PREFIX.sub("", NUMBERING.sub("", norm(text))))
if len(core) < 10:
return False
return core[:20] in self.text or core[-20:] in self.text
def has(self, value: str) -> bool:
v = norm(value)
return len(v) >= 6 and v in self.text
def check_quotes(
docs: list[BidDoc], tender: Tender, tables: list[PriceTable], totals: list[Total], hits: list[Hit]
) -> None:
"""另外给的报价:「备选方案报价」「方案二总价」后面跟着金额(致命);「优惠价 / 让利后」跟着的金额和投标总价
不一样的(重要:多半是另报了一个价,也可能只是说明)。金额等于投标总价(开标一览表或分项合计)的、否定句、
招标文件原话都不算。"""
values = {t.value for t in totals} | {t.total[1] for t in tables if t.total}
for d in docs:
for s in d.sentences:
m = ALT.search(s.text)
if not m:
continue
amounts = money(s.text[m.end() :])
clause = re.split(r"[,。;,;]", s.text[: m.start()])[-1]
if not amounts or NEG.search(clause) or tender.restated(s.text) or set(amounts) & values:
continue
fatal = bool(ALT_FATAL.search(m.group(0)))
hits.append(
Hit(
"PRICE.MULTIPLE_QUOTES",
"price_form",
"fatal" if fatal else "important",
"投标文件里另外给了一个报价" if fatal else "投标文件里有另一个价格",
f"「{quote(s.text)}」。招标文件允许备选方案的除外,一份投标只能有一个报价,请核对。",
[(d, s)],
target(s.text),
)
)
PAGE_NO = re.compile(r"^[-—\s第]*\d+[-—\s页]*(/\s*\d+|共\d+页)?[-—\s]*$")
CODE = re.compile(r"[A-Za-z]{2,}[A-Za-z0-9]*(?:[--—_][A-Za-z0-9]+){2,}")
ANNOT = re.compile(
r"([^,。;::、()()]{2,60})"
r"[((](项目名称|项目编号|采购编号|招标编号|采购项目编号|采购人名称|采购人)[))]"
)
CODE_LABEL = re.compile(r"(项目编号|采购编号|招标编号|采购项目编号)(?:为|是)?[::]?\s*(" + CODE.pattern + ")")
TO = re.compile(r"^致[::]\s*([^((,。]{2,40})")
LEAD = re.compile(r"^(根据|参加|就|对|按|在|为|关于|贵方|贵单位|贵中心|贵公司)+")
GENERIC_OWNER = re.compile(r"^(贵方|贵单位|贵中心|贵公司|采购人|招标人|采购代理机构|代理机构|招标代理机构|采购单位)?$")
HEADER_TAIL = re.compile(r"((投标|响应|磋商)文件|[((][^))]*[))]|[-—\s])+$")
PROJECT_END = re.compile(r"(项目|工程|采购|标段|招标|建设)$")
CHAPTER = re.compile(r"^第[一二三四五六七八九十\d]+[章节部分篇]|方案|部分|章节")
BIDDER = re.compile(r"(投标人|供应商|响应人|投标单位)(名称)?(([^)]{0,8}))?[::]\s*([^((,。::]{4,40})")
def _bigrams(s: str) -> set[str]:
return {s[i : i + 2] for i in range(len(s) - 1)}
def same_name(a: str, b: str) -> bool:
"""名称是不是同一个:互相包含,或字对重合度够高(简称、括注、「项目」两字有无都算同一个)。"""
a, b = norm(a).strip("“”\"'"), norm(b).strip("“”\"'")
if not a or not b:
return False
if a in b or b in a:
return True
x, y = _bigrams(a), _bigrams(b)
return bool(x and y) and 2 * len(x & y) / (len(x) + len(y)) >= 0.6
def bidder_name(docs: list[BidDoc]) -> str | None:
"""投标人名称:落款「投标人(供应商)名称:xxx」里出现最多的那个。"""
c: Counter[str] = Counter()
for d in docs:
for s in d.sentences:
if m := BIDDER.search(s.text):
c[norm(m.group(4))] += 1
return c.most_common(1)[0][0] if c else None
def check_foreign(facts: Facts, docs: list[BidDoc], tender: Tender, hits: list[Hit], notes: list[str]) -> None:
"""别的项目留下来的名称、编号、采购人:页眉、格式文件里「xxx(项目名称)」这类填写位、「致:xxx」。
业绩表里的项目名称是投标人自己的业绩,不在填写位里,不查;招标文件里出现过的名称、编号(代理机构编号、
项目简称)不算别的项目。"""
if not (facts.name or facts.code):
notes.append("项目信息里没有项目名称和编号,没有检查是否混入别的项目")
return
bidder = bidder_name(docs)
owners = [x for x in (facts.purchaser, facts.agency) if x]
def ours(text: str) -> bool:
t = LEAD.sub("", norm(text))
if facts.code and norm(facts.code).lower() in t.lower():
return True
if facts.name and same_name(t, facts.name):
return True
return bool(bidder and bidder in t) or tender.has(t)
for d in docs:
seen: set[str] = set()
for s in d.header_footer:
t = norm(s.text)
core = HEADER_TAIL.sub("", t)
looks_like_project = bool(PROJECT_END.search(core) or CODE.search(t)) and not CHAPTER.search(core)
if t in seen or len(core) < 6 or PAGE_NO.match(t) or not looks_like_project or ours(core):
continue
seen.add(t)
pages = [x.page_no for x in d.header_footer if norm(x.text) == t]
hits.append(
Hit(
"FORM.FOREIGN_PROJECT",
"formality",
"important",
"页眉里是别的项目名称",
f"{d.label}的页眉写着「{s.text[:50]}」(共 {len(pages)} 页),"
f"和本项目「{facts.name or facts.code}」不一致。"
"可能是沿用了别的投标文件,请核对。",
[(d, s)],
f"header:{target(t)}",
basis="name",
detail={"pages": pages[:50]},
)
)
for s in d.sentences:
for kind, value in _filled(s.text):
if kind == "code":
bad = bool(facts.code) and norm(facts.code).lower() not in norm(value).lower()
bad = bad and not tender.has(value)
elif kind == "name":
bad = bool(facts.name) and not ours(value)
else:
v = LEAD.sub("", norm(value))
bad = bool(owners) and not GENERIC_OWNER.match(v) and not tender.has(v)
bad = bad and not any(same_name(v, o) for o in owners)
if not bad:
continue
label = {"code": "项目编号", "name": "项目名称", "purchaser": "采购人"}[kind]
expect = {"code": facts.code, "name": facts.name, "purchaser": "、".join(owners)}[kind]
hits.append(
Hit(
"FORM.FOREIGN_PROJECT",
"formality",
"important",
f"{label}和本项目不一致",
f"这里填的{label}是「{value[:40]}」,本项目是「{expect}」。请核对是不是沿用了别的投标文件。",
[(d, s)],
f"{kind}:{target(value)}",
basis=kind,
)
)
def _filled(text: str) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for m in ANNOT.finditer(text):
value, label = LEAD.sub("", m.group(1)).strip(), m.group(2)
if not value or re.fullmatch(r"[\s__]*", value):
continue
if "编号" in label:
codes = CODE.findall(value)
if codes:
out.append(("code", codes[-1]))
elif label == "项目名称":
out.append(("name", value))
else:
out.append(("purchaser", value))
for m in CODE_LABEL.finditer(text):
if ("code", m.group(2)) not in out:
out.append(("code", m.group(2)))
if m := TO.match(norm(text)):
out.append(("purchaser", m.group(1)))
return out
BLANKS = (
(re.compile(r"[__]{3,}"), "下划线处没有填"),
(re.compile(r"(?<![\d〇零一二三四五六七八九十])(?<!出生)(?<!成立)(?<!注册)年\s*月\s*日"), "日期没有填"),
(re.compile(r"[((]\s*[))](?=\s*[((][^))]{2,12}[))])"), "括号里没有填"),
(re.compile(r"[::]\s*[((]\s*[))]"), "括号里没有填"),
(
re.compile(
r"(?:^|[::,,。;系为致])\s*[((](投标人名称|供应商名称|单位名称|企业名称|姓名|项目名称|项目编号|"
r"采购编号|采购人名称|职务|身份证号码?)[))]"
),
"填写位前面没有内容",
),
(
re.compile(r"(名称|日期|姓名|签字|签章|盖章|电话|地址|账号|开户行|职务|代表人)(([^)]{0,12}))?[::]\s*$"),
"冒号后面没有填",
),
)
BLANK_CAP = 30
def check_blanks(docs: list[BidDoc], hits: list[Hit], notes: list[str]) -> None:
"""格式文件里该填没填的地方(提示级)。「(盖单位章)」「(签字)」这类括注本来就留着,不算。"""
n = 0
for d in docs:
rows = row_index(d)
for s in d.sentences:
why = next((w for p, w in BLANKS if p.search(s.text)), None)
if why is None:
continue
row = rows.get(id(s), [])
if why == "冒号后面没有填" and row and row[-1] is not s:
continue # 表格里「名称:」后面一格填了
n += 1
if n > BLANK_CAP:
continue
hits.append(
Hit(
"FORM.BLANK_FIELD",
"formality",
"hint",
"可能有没填的地方",
f"「{quote(s.text)}」:{why}。请核对是否需要填写。",
[(d, s)],
target(s.text),
)
)
if n > BLANK_CAP:
notes.append(f"疑似没填的地方共 {n} 处,只列出前 {BLANK_CAP} 处")
DATE = re.compile(r"(\d{4})\s*[年.\-/]\s*(\d{1,2})\s*[月.\-/]\s*(\d{1,2})\s*日?")
CN_DATE = re.compile(
r"([〇零○一二三四五六七八九]{4})\s*年\s*([一二三四五六七八九十]{1,3})\s*月\s*([一二三四五六七八九十]{1,3})\s*日"
)
SIGN_LABEL = re.compile(r"日\s*期\s*[::]\s*")
SIGN_PREFIX = ("签署", "落款", "投标", "签字", "填报", "编制", "响应", "授权")
def _cn_year(s: str) -> int:
return int("".join(str(CN.get(c, 0)) for c in s.replace("○", "〇")))
def _sign_dates(text: str) -> list[tuple[str, date | None]]:
"""落款日期:「日期:2026年11月17日」「日 期:二〇二六年十一月十七日」(前面紧挨着的是「签署 / 落款 / 投标」
或不是汉字;「发证日期」「截止日期」不算)。写错的日期返回 None。"""
out: list[tuple[str, date | None]] = []
for m in SIGN_LABEL.finditer(text):
before = text[: m.start()]
if before and "一" <= before[-1] <= "鿿" and not before.endswith(SIGN_PREFIX):
continue
if dm := DATE.match(text, m.end()):
y, mo, da = (int(x) for x in dm.groups())
elif dm := CN_DATE.match(text, m.end()):
y, mo, da = _cn_year(dm.group(1)), cn_int(dm.group(2)) or 0, cn_int(dm.group(3)) or 0
else:
continue
try:
out.append((dm.group(0), date(y, mo, da)))
except ValueError:
out.append((dm.group(0), None))
return out
def check_dates(facts: Facts, docs: list[BidDoc], hits: list[Hit], notes: list[str]) -> None:
if facts.deadline is None:
notes.append("项目信息里没有投标截止时间,落款日期没有比对")
for d in docs:
found: dict[str, tuple[date | None, list[Sentence]]] = {}
for s in d.sentences:
for raw, value in _sign_dates(s.text):
if value is None or (facts.deadline and value > facts.deadline):
found.setdefault(raw, (value, []))[1].append(s)
for raw, (value, sents) in found.items():
if value is None:
title, text = "落款日期写法有误", f"落款日期写的是「{raw}」,不是一个有效的日期。"
else:
title = "落款日期晚于投标截止时间"
text = f"落款日期写的是「{raw}」,投标截止时间是 {facts.deadline:%Y年%m月%d日}。"
hits.append(
Hit(
"FORM.DATE_AFTER_DEADLINE",
"formality",
"hint",
title,
text + (f"共 {len(sents)} 处。" if len(sents) > 1 else "") + "请核对。",
[(d, s) for s in sents[:5]],
f"{d.no}:{norm(raw)}",
basis="deadline" if value else None,
)
)
NUM = r"([0-9]+|[零一二两三四五六七八九十百]+)\s*(?:[((]\s*([0-9]+)\s*[))])?"
UNIT = r"(个月|个?日历[天日]|个?工作日|日|天)"
VALID = re.compile(r"(投标|响应|磋商|谈判)(文件)?有效期[^。;,,内]{0,30}?" + NUM + r"\s*[((]?" + UNIT)
VALID_IN = re.compile(NUM + r"\s*[((]?" + UNIT + r"[))]?\s*内[^。;]{0,4}(投标|响应)?有效")
VALID_HEAD = re.compile(r"^(投标|响应|磋商|谈判)(文件)?有效期[((]?[^))]{0,6}[))]?$")
DAYS = re.compile(r"^" + NUM + r"\s*[((]?" + UNIT)
LETTER = re.compile(r"(投标|响应|磋商|谈判)函|开标一览表|报价一览表|响应一览表")
def _days(n: str, paren: str | None, unit: str) -> int | None:
v = int(paren) if paren else cn_int(n)
return None if not v else v * 30 if "月" in unit else v
def validity_days(text: str) -> list[int]:
"""投标有效期写了多少天(「九十(90)天」取括号里的数字;「三个月」按 90 天)。"""
out = [v for m in VALID.finditer(text) if (v := _days(m.group(3), m.group(4), m.group(5)))]
out += [v for m in VALID_IN.finditer(text) if (v := _days(m.group(1), m.group(2), m.group(3)))]
return out
def required_validity(items: dict[UUID, dict[str, Any]]) -> tuple[int | None, UUID | None]:
"""清单里的投标有效期要求(日期类、date_kind = validity 的条目)。有几条时取天数最少的:
只在投标文件比所有写法都短时才报,不拿最严的一条去卡。"""
best: tuple[int | None, UUID | None] = (None, None)
for item_id, p in items.items():
if ((p.get("spec") or {}).get("schedule") or {}).get("date_kind") != "validity":
continue
days = validity_days(f"{p.get('title') or ''}。{p.get('requirement_text') or ''}")
if days and (best[0] is None or min(days) < best[0]):
best = (min(days), item_id)
return best
def _table_validity(doc: BidDoc) -> list[tuple[Sentence, int]]:
"""表格里的写法:「投标有效期」一格,右边一格或下一行同一栏写「90 天」。"""
out: list[tuple[Sentence, int]] = []
for _, page in sorted(pages_of(doc).items()):
rows = rows_of(page)
for i, row in enumerate(rows):
for n, c in enumerate(row):
if not VALID_HEAD.match(norm(c.text)):
continue
x0, x1 = _span(c)
below = rows[i + 1] if i + 1 < len(rows) else []
cands = [*row[n + 1 : n + 2], *(x for x in below if min(x1, _span(x)[1]) > max(x0, _span(x)[0]))]
for x in cands:
if (m := DAYS.match(norm(x.text))) and (v := _days(m.group(1), m.group(2), m.group(3))):
out.append((x, v))
break
return out
def check_validity(facts: Facts, docs: list[BidDoc], tender: Tender, hits: list[Hit], notes: list[str]) -> None:
"""投标有效期短于要求:投标函、开标一览表那一页上的按致命报(招标文件通常把它列为无效情形);
别处的(技术分册、附件里的一句话)按提示报。复述的招标原文不算。"""
if facts.validity_days is None:
notes.append("清单里没有投标有效期的天数要求,投标有效期没有比对")
return
for d in docs:
letter_pages = {no for no, page in pages_of(d).items() if LETTER.search(norm("".join(s.text for s in page)))}
found = [(s, v) for s in d.sentences if not tender.restated(s.text) for v in validity_days(s.text)]
for s, v in [*found, *_table_validity(d)]:
if v >= facts.validity_days:
continue
fatal = s.page_no in letter_pages
hits.append(
Hit(
"FORM.VALIDITY_SHORT",
"formality",
"fatal" if fatal else "hint",
"投标有效期短于招标文件要求",
f"投标文件写的投标有效期是 {v} 天,招标文件要求 {facts.validity_days} 天。"
+ ("招标文件通常把投标有效期不满足要求列为无效情形,请核对。" if fatal else "请核对。"),
[(d, s)],
target(s.text),
basis="validity",
item=facts.validity_item,
detail={"bid_days": v, "required_days": facts.validity_days},
)
)
CONDITIONS = (
re.compile(
r"(报价|价格|总价|单价|投标价)[^。;]{0,8}(不|未)(包含|包括|含)[^。;]{0,8}(税|运|安装|调试|培训|运输|保险|费)"
),
re.compile(r"按(实际|实际发生|实际需求|实际使用)[^。;,]{0,10}(结算|计算|计价|收费|计费)"),
re.compile(r"(费用|费)[^。;,]{0,6}由(采购人|甲方|贵方|用户|业主)[^。;,]{0,4}(承担|负责|支付|另付)"),
re.compile(r"另行(计算|计取|收取|收费|结算|报价|协商|商定|商议|约定|确定)|另议|另计"),
re.compile(r"以(实际|最终|现场|实际发生)[^。;,]{0,12}为准"),
re.compile(r"(价格|报价|单价|总价)[^。;]{0,10}(随|根据|按照?)[^。;]{0,12}(调整|浮动|变动)"),
re.compile(r"仅供参考"),
re.compile(r"(须|需|需要|要求)[^。;]{0,6}(预付|预付款|首付款)"),
)
COMMERCIAL = re.compile(r"报价|价格|价款|总价|单价|费用|税|付款|支付|结算|数量|工程量|到货|交货|工期|质保|合同")
NOT_CONDITION = re.compile(
r"(不|无|未|免|不再|无需|不得|决不|不会)[^。;,]{0,4}(另行|另计|另议)|无效|否则|不予|不接受|免费|不影响|不增加"
)
def check_conditions(docs: list[BidDoc], tender: Tender, hits: list[Hit]) -> None:
"""附加条件:报价、付款、数量、交货上的保留或改动(「报价不含税」「另行协商」「以实际……为准」)。
「不另行收费」「不影响合同价款」这类承诺、招标文件原话(复述的要求)不算;技术分册里的按提示报。"""
for d in docs:
technical = "技术" in d.label
for s in d.sentences:
t = s.text
if len(norm(t)) < 10 or not COMMERCIAL.search(t) or NOT_CONDITION.search(t) or tender.restated(t):
continue
m = next((m for p in CONDITIONS if (m := p.search(t))), None)
if m is None:
continue
hits.append(
Hit(
"FORM.CONDITION_ADDED",
"formality",
"hint" if technical else "important",
"可能附加了条件",
f"「{quote(t)}」。附有采购人不能接受的条件可能导致投标无效,"
"请核对这句话是不是对报价、付款或交货附加了条件。",
[(d, s)],
target(t),
detail={"phrase": m.group(0)},
)
)
RESP_HEAD = re.compile(r"(响应情况|是否响应|响应结论|偏离情况|是否偏离|偏离/响应|响应/偏离|是否满足|符合或偏离)")
NEG_RESP = re.compile(
r"^(未完全响应|不完全响应|部分响应|未响应|不响应|负偏离|有偏离|不满足|部分满足|未满足|不符合|部分符合)"
r"([((].*)?[。;]?$"
)
YES_NO_HEAD = re.compile(r"是否(响应|满足|符合)")
DEVIATE_HEAD = re.compile(r"是否偏离")
FATAL_TABLE = re.compile(r"(符合性|实质性|资格)")
SERIAL = re.compile(r"\d{1,3}")
def _negative(cell: str, head: str) -> bool:
t = norm(cell)
if NEG_RESP.match(t) or deviation.negative(t):
return True
if YES_NO_HEAD.search(head) and t in ("否", "×"):
return True
return bool(DEVIATE_HEAD.search(head)) and t == "是"
def check_response_tables(docs: list[BidDoc], hits: list[Hit]) -> None:
"""响应表(表头有「响应情况 / 偏离情况」一栏):这一栏写了「未完全响应」「负偏离」「否」之类,或者空着。
一条记录从序号行开始,到下一个序号行为止(垂直居中的格子、折行可能落在没有序号的行里)。
表名、表头写「符合性 / 实质性 / 资格」的写了不响应按致命报,其余按重要报;「空着」可能是版面没认准,按重要报。
能对上清单项的,响应匹配也会报,写入前按投标侧句子去重(drop_covered)。"""
for d in docs:
for _, page in sorted(pages_of(d).items()):
rows = rows_of(page)
for i, row in enumerate(rows):
at = next((n for n, c in enumerate(row) if RESP_HEAD.search(norm(c.text)) and len(c.text) <= 40), None)
if at is None or len(row) < 3:
continue
head = norm(row[at].text)
cols = {f"c{n}": _span(c) for n, c in enumerate(row)}
want = f"c{at}"
context = norm("".join(c.text for r in rows[max(0, i - 2) : i + 1] for c in r))
fatal = bool(FATAL_TABLE.search(context))
records: list[list[Sentence]] = []
for r in rows[i + 1 :]:
numbered = len(r) >= 2 and SERIAL.fullmatch(norm(r[0].text)) is not None
if not numbered and RESP_HEAD.search(norm("".join(c.text for c in r))):
break # 下一张表的表头(表体每行都以序号开头,要求栏里也可能写着「……的响应情况」)
if numbered:
records.append(list(r))
elif len(r) == 1 and len(norm(r[0].text)) > 40:
break # 表格结束(正文段落)
elif records:
records[-1] += r
for rec in records:
resp = [c for c in rec if _col(c, cols) == want]
others = [c for c in rec[1:] if not any(c is x for x in resp)]
if not others:
continue
req = others[0].text
bad = next((c for c in resp if _negative(c.text, head)), None)
if resp and bad is None:
continue
if bad is not None:
hits.append(
Hit(
"FORM.RESPONSE_NEGATIVE",
"negative_deviation",
"fatal" if fatal else "important",
f"响应表里写了「{norm(bad.text)}」",
f"「{req[:40]}」这一行写的是「{norm(bad.text)}」。"
+ (
"表名写着符合性 / 实质性审查,这类要求不满足通常会导致投标无效,请核对。"
if fatal
else "请核对。"
),
[(d, c) for c in rec],
f"{d.no}:{target(req)}",
)
)
else:
hits.append(
Hit(
"FORM.RESPONSE_NEGATIVE",
"formality",
"important",
"响应表里有一行没有填响应情况",
f"「{req[:40]}」这一行没有读到响应情况(可能空着,也可能是版面没认准),请核对。",
[(d, c) for c in rec],
f"{d.no}:{target(req)}",
)
)
def drop_covered(hits: list[Hit], covered: set[tuple[str, int, str]]) -> list[Hit]:
"""响应匹配已经按清单项报了「负偏离」的句子,规则不再重复报。covered:(分册名称, 页码, 句子原文)。"""
return [
h
for h in hits
if h.code != "FORM.RESPONSE_NEGATIVE"
or not any((d.label, x.page_no, norm(x.text)) in covered for d, x in h.evidence)
]
def run(facts: Facts, docs: list[BidDoc], tender_sentences: list[Sentence]) -> tuple[list[Hit], dict[str, Any]]:
"""跑全部规则,返回问题和覆盖范围说明(checked / not_checked)。"""
tender = Tender(tender_sentences)
tables = [t for d in docs for t in price_tables(d)]
totals = [x for d in docs for x in bid_totals(d)]
hits: list[Hit] = []
notes: list[str] = []
check_prices(facts, docs, tables, totals, hits, notes)
check_item_caps(facts, tables, hits, notes)
check_quotes(docs, tender, tables, totals, hits)
check_foreign(facts, docs, tender, hits, notes)
check_blanks(docs, hits, notes)
check_dates(facts, docs, hits, notes)
check_validity(facts, docs, tender, hits, notes)
check_conditions(docs, tender, hits)
check_response_tables(docs, hits)
out: dict[tuple[str, str], Hit] = {}
for h in hits:
out.setdefault((h.code, h.target), h) # 同一规则同一目标只留一条(match_key 唯一)
coverage = {
"checked": "报价形式(大小写、分项合计、限价、多个报价)和形式要件(别的项目名称、没填的地方、落款日期、"
"投标有效期、附加条件、响应表里的未响应):按规则检查",
"not_checked": [*notes, f"{RULES_NOT_YET}:还没有上线"],
}
return list(out.values()), coverage