diff --git a/AGENTS.md b/AGENTS.md index 30e1661..0b8bc4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 diff --git a/apps/web/src/pages/BidCheckPage.vue b/apps/web/src/pages/BidCheckPage.vue index 3972220..452ffeb 100644 --- a/apps/web/src/pages/BidCheckPage.vue +++ b/apps/web/src/pages/BidCheckPage.vue @@ -194,12 +194,14 @@ async function start() {
:
+ 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"(? 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} 元"
+
+
+# ---------- 版面:页、行、单元格 ----------
+def _box(s: Sentence) -> tuple[float, float, float, float]:
+ return (
+ min(q[0] for q in s.quads),
+ min(q[1] for q in s.quads),
+ max(q[2] for q in s.quads),
+ max(q[3] for q in s.quads),
+ )
+
+
+def pages_of(doc: BidDoc) -> dict[int, list[Sentence]]:
+ out: dict[int, list[Sentence]] = {}
+ for s in doc.sentences:
+ out.setdefault(s.page_no, []).append(s)
+ return out
+
+
+@dataclass(slots=True)
+class _Row:
+ top: float
+ bottom: float
+ in_cell: bool
+ items: list[Sentence]
+
+
+def rows_of(page: list[Sentence]) -> list[list[Sentence]]:
+ """把一页的句子分成行,行内从左到右。有框线的表格按单元格的上沿分行:同一行的格子上沿相同,
+ 垂直居中的短格、折成几句的长格、纵向合并的格(只算进它的第一行)都不会串行;其余按上下范围重叠。"""
+ rows: list[_Row] = []
+
+ def top_of(s: Sentence) -> float:
+ return s.cell[1] if s.cell else _box(s)[1]
+
+ for s in sorted((x for x in page if x.quads), key=top_of):
+ if s.cell:
+ r = next((r for r in rows if r.in_cell and abs(r.top - s.cell[1]) < 2), None)
+ if r:
+ r.items.append(s)
+ else:
+ rows.append(_Row(s.cell[1], s.cell[1], True, [s]))
+ continue
+ _, top, _, bottom = _box(s)
+ last = rows[-1] if rows else None
+ if last and not last.in_cell and top < last.bottom - 1:
+ last.bottom = max(last.bottom, bottom)
+ last.items.append(s)
+ else:
+ rows.append(_Row(top, bottom, False, [s]))
+ return [sorted(r.items, key=lambda x: _box(x)[0]) for r in sorted(rows, key=lambda r: r.top)]
+
+
+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}
+
+
+def _col(cell: Sentence, cols: dict[str, tuple[float, float]]) -> str | None:
+ """单元格落在哪一栏:和表头单元格左右范围重叠最多的那一栏;都不重叠(窄数字居中、表头字靠左)取中心最近的。
+ cols 要包含表头的每一格,否则别的栏的数字会被归到最近的已知栏。"""
+ x0, x1 = _span(cell)
+ best, score = None, 0.0
+ for name, (c0, c1) in cols.items():
+ overlap = min(x1, c1) - max(x0, c0)
+ if overlap > score:
+ best, score = name, overlap
+ if best is None and cols:
+ mid = (x0 + x1) / 2
+ best = min(cols, key=lambda n: abs((cols[n][0] + cols[n][1]) / 2 - mid))
+ return best
+
+
+def _span(c: Sentence) -> tuple[float, float]:
+ return (c.cell[0], c.cell[2]) if c.cell else (_box(c)[0], _box(c)[2])
+
+
+# ---------- 分项报价表、开标一览表 ----------
+@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)},
+ )
+ )
+
+
+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"(? 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):
+ 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_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
diff --git a/server/src/bid_doctor/check/runner.py b/server/src/bid_doctor/check/runner.py
index 3b738b8..846ad40 100644
--- a/server/src/bid_doctor/check/runner.py
+++ b/server/src/bid_doctor/check/runner.py
@@ -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 {
diff --git a/server/src/bid_doctor/check/service.py b/server/src/bid_doctor/check/service.py
index 3cdbe9a..693eba5 100644
--- a/server/src/bid_doctor/check/service.py
+++ b/server/src/bid_doctor/check/service.py
@@ -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],
diff --git a/server/src/bid_doctor/document/pipeline/text_layer.py b/server/src/bid_doctor/document/pipeline/text_layer.py
index fc0072a..153604b 100644
--- a/server/src/bid_doctor/document/pipeline/text_layer.py
+++ b/server/src/bid_doctor/document/pipeline/text_layer.py
@@ -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(
diff --git a/server/tests/fixtures/bid-mini-forms.pdf b/server/tests/fixtures/bid-mini-forms.pdf
new file mode 100644
index 0000000..c9a17e0
Binary files /dev/null and b/server/tests/fixtures/bid-mini-forms.pdf differ
diff --git a/server/tests/fixtures/make_bid_fixtures.py b/server/tests/fixtures/make_bid_fixtures.py
index fabe896..18be895 100644
--- a/server/tests/fixtures/make_bid_fixtures.py
+++ b/server/tests/fixtures/make_bid_fixtures.py
@@ -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 = """"""
COMMON = [
@@ -41,6 +44,16 @@ def render(name: str, parts: list[str]) -> None:
print(name, len(r.content))
+FORMS = [
+ "五、法定代表人授权委托书。本人王某(姓名)系(投标人名称)的法定代表人,现授权李某为我方代理人。
",
+ "六、开标一览表
",
+ "项目名称 投标总报价(元) 投标总报价(大写) "
+ "某市智慧政务系统集成项目 1,280,000.00 壹拾贰万捌仟元整
",
+ "让利后总价:1,250,000.00元。
",
+ "七、本次报价未包含设备运输费用,运输费用由采购人另行承担。
",
+]
+
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])
diff --git a/server/tests/test_check_flow.py b/server/tests/test_check_flow.py
index 688df01..dbdef6e 100644
--- a/server/tests/test_check_flow.py
+++ b/server/tests/test_check_flow.py
@@ -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"
diff --git a/server/tests/test_check_rules.py b/server/tests/test_check_rules.py
new file mode 100644
index 0000000..8ff8479
--- /dev/null
+++ b/server/tests/test_check_rules.py
@@ -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"),
+ }