- deviation.py:模型判已响应的条目,证据所在表格行的偏离格写着「偏离 / 部分满足」等 就改判负偏离;照抄要求格、响应格写的是别的内容的改判「响应可能对不上」 - reuse.py:按「提示词 + 要求 + 候选句原文」算缓存键,存模型原始结论;复查时内容没变、 证据找得回的条目直接沿用,不调模型 - 每次检查送给模型的字数有上限,超出的条目列为请人工核对,写进覆盖范围 - 开发集 17 份:召回 61% → 73%,负偏离未说明 64% → 90%,规矩投标文件问题数不变 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1110 lines
52 KiB
Python
1110 lines
52 KiB
Python
"""规则检查:报价形式和形式要件(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
|