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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

Binary file not shown.

View File

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

View File

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

View File

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