fix(check): 修国动真实投标文件暴露的 6 处(规则集 r5)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

国动真实投标文件查标一次(check-eval §14):44 个问题里有用的 9 个,
致命、重要两级都是误报。按决策 E3 全部修(§15):
- 响应错位:「完全满足,无偏离。」这类复合肯定写法整体算肯定
- 附加条件:「不以……另行收费」是承诺
- 扫描件:文字层 v5 记每页图片占比,带一行说明的扫描页也认;提示写明扫描页范围
- 不需要这家投标人回答的:无特定资格要求、总括性资格要求、中标后才做的事、
  投标文件没声称适用的联合体和价格扣除
- 「方案应明确 A、B、C……」逐项再检索
- 同一批送出的片段都可引用(引文照样核对);出处没过校验的不沿用

国动重新检查 44 → 11(全是提示,有用的 9 个都在);开发集召回 112 → 111
(模型重跑波动),致命级 25/32 不变,规矩的投标文件问题 239 → 213。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 13:16:12 +08:00
parent f5edc585f1
commit 808259da87
16 changed files with 373 additions and 21 deletions

View File

@@ -5,13 +5,18 @@
- 无效情形、评审规定(只有「无效」类别、不是标注出来的条款):由规则检查(第 2 步)和人工核对覆盖;
- 日期与递交:由规则检查(落款日期、有效期)和需人工核对覆盖;
- 评分标准本身:没写要附什么材料的不找;写了的按证明材料找;
- 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找。
- 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找;
- 写明没有的要求(无特定资格要求)、总括性的「须具备招标文件规定的资格要求」、
中标或签订合同以后才做的事,不找;
- 条件性要求(联合体、残疾人福利性单位 / 监狱企业 / 本国产品的价格扣除):投标文件里有对应的文件、
说明投标人声称适用时才找(国动真实投标文件,check-eval §14)。
其余按类别取招标文件的响应方式:技术参数用技术类,其余用商务类。
"""
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
@@ -22,15 +27,50 @@ EVIDENCE = re.compile(r"(提供|附|出具)[^。;]{0,20}(证明|报告|截图|
# 「签订合同时提供」才算中标后;「以合同签订时间为准」(业绩条款常见)不是
AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)(?!间)|合同签订(前|时)(?!间))")
NO_PROOF = re.compile(r"无需提供|不需提供|无须提供|采购人[^。;]{0,12}(查询|核查|查验)|信用中国|信用记录")
# 写明没有要求、不接受:没有要投标人提供的东西(按标题判断,标题是读标写的一句话概括)
# 「不接受联合体」不放这里:投标人真是联合体时恰恰要报,按条件性要求处理
NONE_REQUIRED = re.compile(r"无特定资格要求|特定资格要求[::]?(无|/)")
SPECIAL_PROOF = re.compile(r"特定资格要求的证明") # 招标文件写了「无特定资格要求」时不用交
UMBRELLA = re.compile(
r"(具备|满足|符合)[^。;]{0,4}(招标|磋商|采购|谈判|询价)文件[^。;]{0,4}(规定|要求)的?资格(要求|条件)"
)
AFTER_AWARD = re.compile(r"^(合同签订后|签订合同后|中标后|成交后)")
# 条件性要求:标题里的主体 → 投标文件里出现了就说明投标人声称适用
# (「残疾人福利性单位声明函」写「不属于」也算出现,照常查)
CONDITIONAL = {
"consortium": (re.compile(r"联合体"), re.compile(r"联合体(协议|牵头|各方|成员)")),
"disabled": (re.compile(r"残疾人福利"), re.compile(r"残疾人福利性单位")),
"prison": (re.compile(r"监狱|戒毒"), re.compile(r"监狱企业|戒毒企业")),
"domestic": (re.compile(r"本国产品"), re.compile(r"本国产品")),
}
SKIP_LABELS = {
"condition": "无效情形和评审规定",
"schedule": "日期与递交要求",
"scoring": "评分标准(没要求附证明材料)",
"no_proof": "资格条件(写明无需提供证明,或由采购人查询)",
"price": "报价类要求(分项、单价上限):由规则比对分项报价表",
"none_required": "写明没有的要求(无特定资格要求)",
"umbrella": "总括性的资格要求(由各条具体资格条件覆盖)",
"after_award": "中标或签订合同以后才做的事",
"not_claimed": "条件性要求(联合体、残疾人福利性单位、监狱企业、本国产品),投标文件没有声称适用",
}
@dataclass(frozen=True, slots=True)
class Context:
"""整份清单和投标文件上的事实。claims 为 None 时不知道投标文件写了什么,条件性要求照常查。"""
claims: frozenset[str] | None = None
no_special: bool = False # 招标文件写了「无特定资格要求」
def context(items: Iterable[dict[str, Any]], bid_texts: Iterable[str]) -> Context:
bid = "".join(bid_texts)
claims = frozenset(k for k, (_, claimed) in CONDITIONAL.items() if claimed.search(bid))
no_special = any(re.search(r"无特定资格要求|特定资格要求[::]?(无|/)", str(i.get("title") or "")) for i in items)
return Context(claims, no_special)
@dataclass(frozen=True, slots=True)
class Decision:
match: bool
@@ -40,15 +80,26 @@ class Decision:
evidence: bool = False # 要求投标时附证明材料:默认接受也不能免(没附就是没附)
def decide(item: dict[str, Any], modes: dict[str, Mode]) -> Decision:
def decide(item: dict[str, Any], modes: dict[str, Mode], ctx: Context | None = None) -> Decision:
cats = set(item.get("categories") or [])
text = f"{item.get('title') or ''}{item.get('requirement_text') or ''}"
title = str(item.get("title") or "")
text = f"{title}{item.get('requirement_text') or ''}"
if cats == {"invalidity"} and item.get("importance_basis") != "marker":
return Decision(False, skip="condition") # 「……的,投标无效」这类情形;★ / 斜体下划线标注的条款是要求,照常找
if cats == {"price"}:
return Decision(False, skip="price")
if "schedule" in cats and not cats & {"tech_param", "qualification", "scoring"}:
return Decision(False, skip="schedule")
if NONE_REQUIRED.search(title) or (ctx is not None and ctx.no_special and SPECIAL_PROOF.search(title)):
return Decision(False, skip="none_required")
if "tech_param" not in cats and UMBRELLA.search(text):
return Decision(False, skip="umbrella")
if AFTER_AWARD.search(title):
return Decision(False, skip="after_award")
if ctx is not None and ctx.claims is not None:
for key, (subject, _) in CONDITIONAL.items():
if subject.search(title) and key not in ctx.claims:
return Decision(False, skip="not_claimed")
evidence = bool(item.get("evidence_required") or EVIDENCE.search(text)) and not AWARD.search(text)
if cats <= {"scoring"} and not evidence:
return Decision(False, skip="scoring")

View File

@@ -192,6 +192,10 @@ def direct_read(results: dict[UUID, Result], docs: list[BidDoc]) -> int:
POSITIVE = re.compile(r"^(满足|完全满足|响应|完全响应|已响应|符合|同意|接受)[,,::。;、\s]*")
MARKER = re.compile(r"^(无偏离|正偏离|负偏离|偏离|满足|完全响应|响应|是|否|/|—+|-+)$")
# 「完全满足,无偏离。」「符合,无偏离;」:几个肯定写法连着写,整格仍是肯定回答(国动真实投标文件,check-eval §14)
AFFIRM = re.compile(
r"^(完全满足|满足|完全响应|已响应|响应|完全符合|符合|同意|接受|无偏离|无负偏离|不偏离|完全一致|一致)[,,::。;;、\s]*"
)
POINTER = re.compile(r"详见|参见|见附件|见第|第.{0,6}页|如下|见下|见上|附后|另附|承诺")
GENERIC = re.compile(r"^(支持|具备|提供|有|可以|能|是|均支持|已具备|可实现|实现)[。;;]?$") # 笼统的肯定回答
CJK = re.compile(r"[一-鿿]")
@@ -213,6 +217,16 @@ def _similar(a: str, b: str) -> float:
CERT_NAME = re.compile(r"(证书|许可证|资质|认证)$")
def _beyond_affirm(text: str) -> str:
"""去掉开头连着写的肯定回答(「完全满足,无偏离;」),剩下的才是响应格里另外写的内容。"""
t = norm(text)
while m := AFFIRM.match(t):
if not m.group(0):
break
t = t[m.end() :]
return t
def _cert_name(text: str, item: dict[str, object]) -> bool:
"""资质情况表的名称格(「安全生产许可证」):比要求短、以证书名结尾,右边是编号、有效期,不是照抄的要求格。"""
t = norm(text)
@@ -252,7 +266,7 @@ def mismatch_read(results: dict[UUID, Result], items: dict[UUID, dict[str, objec
if not cells:
continue
d, _, resp = cells[0]
extra = POSITIVE.sub("", norm(resp.text))
extra = _beyond_affirm(resp.text)
if len(extra) < 2 or POINTER.search(extra) or GENERIC.match(extra) or _similar(extra, req) > 0:
continue
r.verdict = "not_found"

View File

@@ -114,10 +114,15 @@ def _spec(result: Result, item: dict[str, Any]) -> Spec | None:
return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"})
if result.verdict == "scanned":
total = result.detail.get("scanned_total") or 0
where = result.detail.get("scanned_where")
# 只说扫描件在哪几页,不说「材料可能在扫描件里」:扫描件上是不是这份材料,没有 OCR 分不出来
text = (
f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"
f"投标文件文字部分没有找到这份材料。{where} 是扫描件(共 {total} 页),系统读不出上面的文字;"
"材料如果附在这些页上,请人工核对,没有附就是缺了。"
if where
else f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"
"材料可能在扫描件页上,请人工核对。"
)
return Spec("manual_review", "hint", f"请人工核对(可能在扫描件里):{name}", text, base)
return Spec("manual_review", "hint", f"请人工核对(文字部分没找到):{name}", text, base)
why = REASONS.get(result.reason_code or "", result.reason_code or "")
return Spec("manual_review", sev, f"未判定:{name}", f"这条要求本次没有完成判断({why}),请人工核对。", base)

View File

@@ -37,6 +37,11 @@ SPLIT_DEPTH = 2
# 定在 5 倍左右:正常文件碰不到,异常大的文件不会无限花钱;超出的条目列为「未判定」,写进覆盖范围
MAX_INPUT_CHARS = 1_200_000
CONCURRENCY = 4 # TODO(第 3 步):和解析共用「供应商 × 租户」统一限流(extract.py 的同一个 TODO)
# 「方案应明确数据标准、更新路径、接口边界、联调安排和异常处理措施」:要的是投标文件里一章的内容,整句检索的
# 前几名常落在别处;列出的每一项再各检索一次,候选句才覆盖得到各项(国动真实投标文件,check-eval §14)
OUTLINE = re.compile(r"(方案|计划|措施)[^。;,]{0,6}(应当|应|须|需)(明确|包括|包含|说明|涵盖|阐述)([^。;]*)")
OUTLINE_SPLIT = re.compile(r"、|以及|及|和|与")
OUTLINE_MAX = 10 # 这类条目的候选句上限(整句检索的 5 句 + 每项 1 句)
SID = re.compile(r"^d(\d+)p(\d+)s(\d+)$")
END_PUNCT = "。;;,,、::))"
VERDICTS = {"responded", "not_found", "negative_deviation", "suspected_unmet", "no_value"}
@@ -109,13 +114,25 @@ def scanned_fallback(
pages = [f"{d.label} 第 {p} 页" for d in docs for p in d.scanned_pages]
if not pages:
return
where = ";".join(f"{d.label}第 {page_ranges(d.scanned_pages)} 页" for d in docs if d.scanned_pages)
for item_id, d in modes.items():
r = results.get(item_id)
cats = set(items.get(item_id, {}).get("categories") or [])
if r is None or r.verdict != "not_found" or not (d.evidence or cats & {"qualification", "scoring"}):
continue
r.verdict, r.evidence_status, r.reason_code = "scanned", "none", None
r.detail = {**r.detail, "scanned_pages": pages[:30], "scanned_total": len(pages)}
r.detail = {**r.detail, "scanned_pages": pages[:30], "scanned_total": len(pages), "scanned_where": where}
def page_ranges(pages: list[int]) -> str:
"""[16, 17, 18, 20] → 「16–18、20」"""
out: list[str] = []
for p in sorted(set(pages)):
if out and p == int(out[-1].split("–")[-1]) + 1:
out[-1] = f"{out[-1].split('–')[0]}–{p}"
else:
out.append(str(p))
return "、".join(out)
def short_sid(doc: BidDoc, s: Sentence) -> str:
@@ -130,6 +147,17 @@ def query_text(item: dict[str, Any]) -> str:
return " ".join(parts)
def outline_terms(item: dict[str, Any]) -> list[str]:
"""「方案应明确 A、B、C……,并确保……」里列出的各项;少于 3 项的不算提纲式要求。"""
m = OUTLINE.search(str(item.get("requirement_text") or item.get("title") or ""))
if not m:
return []
body = re.split(r"[,,]|并|确保|以便", m.group(4))[0]
parts = [x.strip("的等") for x in OUTLINE_SPLIT.split(body)]
terms = [x for x in parts if 2 <= len(x) <= 12]
return terms[:8] if len(terms) >= 3 else []
def _item_block(key: str, item: dict[str, Any], sids: list[str]) -> str:
cats = "、".join(CATEGORY_LABELS.get(str(c), str(c)) for c in item.get("categories") or [])
lines = [f"[{key}] {item.get('title') or ''}"]
@@ -262,6 +290,12 @@ async def run(
results[item_id] = Result("responded", "presence", "verified", evidence=found[: len(wanted)])
continue
hits = index.search(query_text(item), CANDIDATES)
seen = {id(h.sentence) for h in hits}
for term in outline_terms(item):
for h in index.search(term, 1):
if id(h.sentence) not in seen and len(hits) < OUTLINE_MAX:
hits.append(h)
seen.add(id(h.sentence))
if not hits: # 检索不到候选句:不调模型(m3-plan §2.1)
results[item_id] = Result("not_found", "retrieval", detail={"search_scope": scope})
continue
@@ -325,8 +359,11 @@ async def run(
await progress(done[0] / total)
return
answers = {str(a.get("item")): a for a in data.get("results") or [] if isinstance(a, dict)}
# 同一批里的片段只送一次,模型常引用别的条目的候选句(往往就是这条的响应):本批送出的片段都可以引用,
# 引文照样要过原文校验(国动真实投标文件 4 条 sid_unknown,check-eval §14)
shown = {sid: x for _, _, _, allowed in keyed for sid, x in allowed.items()}
for key, item_id, _, allowed in keyed:
r = judge(answers.get(key), allowed, scope)
r = judge(answers.get(key), shown | allowed, scope)
if r.reason_code and r.reason_code != "model_error":
stats.fail(r.reason_code)
results[item_id] = r

View File

@@ -50,6 +50,8 @@ def restore(stored: dict[str, Any], docs: list[BidDoc], run_id: str) -> Result |
"""按上次存下的证据位置在本次的投标文件里找回句子(原文也要一样),找不全返回 None。"""
from bid_doctor.check.match import Result
if (stored.get("detail") or {}).get("claimed_verdict"):
return None # 出处没过校验的结论(r5 以前也存了):不沿用,重新问
by_pos: dict[tuple[str, int, int], tuple[BidDoc, Sentence]] = {
(str(d.id), s.page_no, s.seq): (d, s) for d in docs for s in d.sentences
}

View File

@@ -1000,6 +1000,8 @@ COMMERCIAL = re.compile(
NOT_CONDITION = re.compile(
r"(不|无|未|免|不再|无需|不得|决不|不会)[^。;,]{0,4}(另行|另计|另议)|无效|否则|不予|不接受|免费|不影响|不增加"
r"|(不|不得|不会|决不)以任何理由"
# 「不以正常接口对接和适配工作另行收费」「不以维护、适配或缺陷修复为由另行收费」:承诺,不是附加条件
r"|(不|不得|不会|决不|不再)以[^。;,]{0,24}(另行|另计|另议)"
)

View File

@@ -184,7 +184,8 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
items[item_id] = payload
else:
other_lot += 1 # 不在所投包里的条目不查,写进覆盖范围(C6)
decisions = {i: applicability.decide(p, modes) for i, p in items.items()}
known = applicability.context(snapshot.values(), (x.text for d in bid_docs for x in d.sentences))
decisions = {i: applicability.decide(p, modes, known) for i, p in items.items()}
skipped: dict[str, int] = {}
for d in decisions.values():
if not d.match and d.skip:
@@ -200,7 +201,12 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
await _stage(ctx, run_id, "match", pct)
results, stats = await match.run(ctx, run_id, items, bid_docs, progress, prior)
raw = {i: reuse.snapshot(x) for i, x in results.items() if x.layer == "model" and x.verdict != "undetermined"}
# 出处没过校验的不沿用:下次检查重新问(沿用回来会丢掉「出处没能核对」的状态)
raw = {
i: reuse.snapshot(x)
for i, x in results.items()
if x.layer == "model" and x.verdict != "undetermined" and x.evidence_status != "unverified"
}
deviation.direct_read(results, bid_docs) # 证据所在的表格行里偏离格写着「偏离」:改判负偏离
deviation.mismatch_read(results, items, bid_docs) # 证据只是照抄的要求格、响应格写的是别的:改判未找到
match.apply_modes(results, items, decisions)

View File

@@ -37,8 +37,8 @@ from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
CHECK_TASK = "check_project"
# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配
RULE_SET_VERSION = "2026.11-r4"
# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配;r5:国动真实文件暴露的 6 处
RULE_SET_VERSION = "2026.11-r5"
WAIVE_WINDOW = timedelta(hours=24)
ERROR_MESSAGES = {
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",

View File

@@ -24,7 +24,8 @@ from bid_doctor.document.pipeline.text_layer import Page
from bid_doctor.document.service import doc_prefix
# text_layer v2:页眉页脚带单独成句,句子编号和 v1 不同(同一份规范化 PDF 在 v1、v2 下的编号不能混用)
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v4", "sentence_split": "s3-v4"}
# text_layer v5:每页记图片占比(扫描件判断),句子和编号和 v4 相同;投标分册下次检查时重新抽一遍
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v5", "sentence_split": "s3-v4"}
OCR_REQUIRED_RATIO = 0.5 # 超过一半的页没有文字层:招标文件按「需要 OCR」失败
OnStage = Callable[[str], Awaitable[None]]

View File

@@ -123,10 +123,18 @@ class Page:
rotation: int
char_count: int
sentences: list[Sentence]
image_cover: float = 0.0 # 图片占页面的比例(text_layer v5 起有;更早的文字层读出来是 0)
@property
def text_source(self) -> str:
return "text" if self.char_count >= 20 else "empty"
"""扫描件页算「没有文字层」(empty)。扫描件常带一行文字说明
(「合同扫描件第 2 页(共 15 页)」「2.1 营业执照」),只看字数会漏掉(国动真实投标文件 60 多页,
check-eval §14):图片占两成以上、不到 60 字,或占四成半以上、不到 120 字,也算扫描件;
带插图的正文页字多,不算。"""
n, cover = self.char_count, self.image_cover
if n < 20 or (cover >= 0.2 and n < 60) or (cover >= 0.45 and n < 120):
return "empty"
return "text"
def to_json(self) -> dict[str, Any]:
return {
@@ -135,6 +143,7 @@ class Page:
"height_pt": self.height,
"rotation": self.rotation,
"char_count": self.char_count,
"image_cover": self.image_cover,
"sentences": [s.to_json() for s in self.sentences],
}
@@ -147,6 +156,7 @@ class Page:
rotation=d["rotation"],
char_count=d["char_count"],
sentences=[Sentence.from_json(d["page_no"], s) for s in d["sentences"]],
image_cover=d.get("image_cover", 0.0),
)
@@ -337,9 +347,22 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], infos: list
rotation=int(page.rotation or 0) % 360,
char_count=len(chars),
sentences=sentences,
image_cover=_image_cover(page),
)
def _image_cover(page: Any) -> float:
"""图片占页面面积的比例(重叠的不去重,封顶 1),判断扫描件用。"""
area = float(page.width) * float(page.height)
total = 0.0
for im in page.images:
w = min(float(page.width), float(im["x1"])) - max(0.0, float(im["x0"]))
h = min(float(page.height), float(im["bottom"])) - max(0.0, float(im["top"]))
if w > 0 and h > 0:
total += w * h
return round(min(total / area, 1.0), 3) if area else 0.0
def extract(doc_id: str, pdf: bytes) -> list[Page]:
with pdfplumber.open(io.BytesIO(pdf)) as doc:
pages = [(p, *_fragments([c for c in p.chars if str(c["text"]).strip()], _cells(p))) for p in doc.pages]

View File

@@ -356,3 +356,21 @@ def test_long_response_mentioning_noncompliance_is_not_negative() -> None:
assert deviation.judge([s("部分响应")], "投标响应情况") == "negative"
assert deviation.judge([s("不满足,现有版本仅支持单机部署")], "投标响应情况") == "negative"
assert deviation.judge([s("无偏"), s("离")], "偏离") == "ok"
def test_negated_extra_charge_is_a_promise_not_a_condition() -> None:
"""「不以……另行收费」「不以……为由另行计取」是承诺(国动真实投标文件误报,check-eval §14);
真的附加了条件(「另行收取」「另行协商」)照报。"""
d = doc(
[
para(1, "数据迁移和接口改造均包含在合同价中,不以正常适配工作另行收费。"),
para(2, "质保期内不以系统升级、兼容调整为由另行计取费用,售后服务全部免费。"),
para(3, "现场驻场服务费用按天另行收取,不含在本次报价中。"),
para(4, "超出质保期的维护费用由双方另行协商确定。"),
]
)
hits, _ = rules.run(rules.Facts(), [d], [])
assert sorted((h.code, h.evidence[0][1].seq) for h in hits) == [
("FORM.CONDITION_ADDED", 3),
("FORM.CONDITION_ADDED", 4),
]

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from uuid import uuid4
import pytest
from bid_doctor.check import findings, match
from bid_doctor.check.retrieval import Index, tokens
from bid_doctor.document.pipeline.text_layer import Sentence
@@ -158,15 +160,16 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
b: {"categories": ["tech_param"], "title": "内存"},
}
decisions = {a: applicability.Decision(True, evidence=True), b: applicability.Decision(True)}
docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13])]
docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13, 14, 20])]
match.scanned_fallback(results, items, decisions, docs)
spec = findings.spec(results[a], items[a])
assert (
results[a].verdict == "scanned"
and spec is not None
and spec.severity == "hint"
and "2 页扫描件" in spec.explanation
and "资格证明册第 12–14、20 页 是扫描件(共 4 页)" in spec.explanation
)
assert match.page_ranges([5, 3, 4, 9]) == "3–5、9"
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到
@@ -240,6 +243,11 @@ def test_mismatch_read_only_when_response_cell_is_unrelated() -> None:
("支持端口汇聚、端口流量统计", "满足,支持端口汇聚和端口流量统计"),
("批量数据处理在非工作时段内完成", "满足,详见技术方案第12页"),
("系统支持国产操作系统", "满足"),
# 几个肯定写法连着写(国动真实投标文件,check-eval §14):仍是肯定回答
("支持按区域、距离和服务类型筛选并排序", "完全满足,无偏离。"),
("后台支持批量导入并提示错误行", "符合,无负偏离;"),
("支持离线缓存最近浏览的十条记录", "完全满足,无偏离;页面截图详见附件三"),
("短信验证码有效期不超过五分钟", "完全满足,显示器升降行程:150mm"),
]
sents, results, items = [], {}, {}
for n, (req, resp) in enumerate(rows):
@@ -254,9 +262,18 @@ def test_mismatch_read_only_when_response_cell_is_unrelated() -> None:
results[k] = (a, b)
d = match.BidDoc(uuid4(), 1, "技术分册", sents)
res = {k: match.Result("responded", "ai", "verified", evidence=[(d, a)]) for k, (a, _) in results.items()}
assert deviation.mismatch_read(res, items, [d]) == 1
assert deviation.mismatch_read(res, items, [d]) == 2
verdicts = [r.verdict for r in res.values()]
assert verdicts == ["not_found", "responded", "responded", "responded"]
assert verdicts == [
"not_found",
"responded",
"responded",
"responded",
"responded",
"responded",
"responded",
"not_found",
]
first = next(iter(res.values()))
assert first.detail["mismatch"] == "显示器升降行程:150mm" and first.reason_code == "response_mismatch"
@@ -346,3 +363,111 @@ def test_direct_read_uses_header_column_and_flags_unclear_values() -> None:
assert results[b].verdict == "responded"
spec = findings.spec(results[a], {"title": "支持双电源", "importance": "substantive", "categories": ["tech_param"]})
assert spec is not None and spec.severity == "important" and spec.title.startswith("偏离栏需核对")
def test_scanned_page_with_caption_counts_as_empty() -> None:
"""扫描件常带一行文字说明(「合同扫描件第 2 页(共 15 页)」):按图片占比和字数一起判断(check-eval §14)。"""
from bid_doctor.document.pipeline.text_layer import Page
def page(chars: int, cover: float) -> str:
return Page(1, 595, 842, 0, chars, [], cover).text_source
assert page(0, 0.0) == "empty"
assert page(26, 0.28) == "empty" # 「2.1 营业执照」+ 执照扫描图
assert page(97, 0.53) == "empty" # 一段说明 + 整页合同扫描
assert page(30, 0.0) == "text" # 只有一行字的正文页(没有图)
assert page(495, 0.25) == "text" # 带架构图的正文页
assert page(179, 0.45) == "text" # 图多、字也不少
assert (
Page.from_json(
{"page_no": 1, "width_pt": 1, "height_pt": 1, "rotation": 0, "char_count": 30, "sentences": []}
).text_source
== "text"
) # v4 文字层没有图片占比
def test_applicability_skips_conditions_the_bidder_does_not_claim() -> None:
"""不需要这家投标人回答的条目(check-eval §14):写明没有的、总括性的、中标后才做的、
投标文件没声称适用的条件性要求(联合体、价格扣除)。声称了(投标文件里有对应文件)就照常查。"""
from bid_doctor.check import applicability
def it(title: str, cats: list[str], req: str = "") -> dict[str, object]:
return {"title": title, "categories": cats, "requirement_text": req or title}
items = [
it("本项目第2包无特定资格要求", ["qualification"]),
it("提交特定资格要求的证明材料", ["qualification"], "特定资格要求的证明材料(如有,必须提供)"),
it("投标人应满足招标文件规定的资格条件", ["qualification"]),
it("中标后十日内提交详细实施计划", ["tech_param"]),
it("联合体投标的,须提交联合体协议书", ["qualification"]),
it("残疾人福利性单位价格扣除", ["scoring"], "提供《残疾人福利性单位声明函》,给予价格扣除。"),
it("提供营业执照副本复印件", ["qualification"]),
]
ctx = applicability.context(items, ["营业执照副本复印件", "我公司不属于残疾人福利性单位。"])
got = [applicability.decide(i, {}, ctx).skip for i in items]
assert got == ["none_required", "none_required", "umbrella", "after_award", "not_claimed", None, None]
# 没有「无特定资格要求」时特定资格证明照查;不知道投标文件写了什么(ctx 为空)时条件性要求照查
assert applicability.decide(items[1], {}, applicability.Context(frozenset())).skip is None
assert applicability.decide(items[4], {}).skip is None
consortium = applicability.context(items, ["联合体协议书(附后)"])
assert applicability.decide(items[4], {}, consortium).skip is None
async def test_citation_from_another_item_in_the_same_batch_is_verified(monkeypatch: pytest.MonkeyPatch) -> None:
"""片段每批只送一次,模型引用了同一批别的条目的候选句:引文核对得上就算(check-eval §14);
没送过的编号照样是 sid_unknown。"""
from bid_doctor.core.db import TenantContext
net = sent(1, "系统部署于互联网区与电子政务外网区,两区之间只经前置交换。", page=7)
mem = sent(2, "服务器内存配置为64GB,满足不低于32GB的要求。", page=9)
d = match.BidDoc(uuid4(), 1, "投标文件", [net, mem])
a, b = uuid4(), uuid4()
items = {
a: {"title": "系统部署在电子政务外网区", "categories": ["tech_param"]},
b: {"title": "服务器内存不低于32GB", "categories": ["tech_param"]},
}
# 前提:每条只取 1 个候选句时,b 的候选句里没有 net(net 只是 a 的候选句)
assert [h.sentence for h in Index([net, mem]).search(match.query_text(items[b]), 1)] == [mem]
async def fake(ctx: object, **kw: object) -> dict[str, object]:
ev = {"sid": "d1p7s1", "quote": "系统部署于互联网区与电子政务外网区"}
return {
"results": [
{"item": "I1", "verdict": "responded", "evidence": [ev]},
{"item": "I2", "verdict": "responded", "evidence": [ev]}, # 引的是 a 的候选句
]
}
monkeypatch.setattr(match, "CANDIDATES", 1)
monkeypatch.setattr(match.ai, "run_json", fake)
async def progress(_: float) -> None:
return None
ctx = TenantContext(tenant_id=uuid4(), user_id=uuid4())
results, _ = await match.run(ctx, uuid4(), items, [d], progress)
assert results[a].verdict == "responded" and results[b].verdict == "responded"
assert results[b].evidence[0][1] is net
unknown = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p8s1", "quote": "x" * 10}]}, {}, [])
assert unknown.reason_code == "sid_unknown"
def test_outline_requirement_is_searched_item_by_item() -> None:
"""「方案应明确 A、B、C……」:列出的每一项再各检索一次(check-eval §14)。"""
item = {"requirement_text": "运维方案应包括巡检频次、备份策略、故障上报流程以及应急演练安排,并确保措施可落地。"}
assert match.outline_terms(item) == ["巡检频次", "备份策略", "故障上报流程", "应急演练安排"]
assert match.outline_terms({"requirement_text": "方案应明确工期。"}) == []
assert match.outline_terms({"requirement_text": "支持巡检、备份和应急演练。"}) == []
def test_reuse_skips_conclusions_whose_citation_failed() -> None:
"""出处没过校验的结论不沿用(复查时重新问模型);正常的结论按证据位置找回来。"""
from bid_doctor.check import reuse
d, allowed = bid()
(_, s) = allowed["d1p1s9"]
ok = match.Result("responded", "model", "verified", evidence=[(d, s)])
assert reuse.restore(reuse.snapshot(ok), [d], "r1") is not None
failed = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p9s9", "quote": "x" * 10}]}, allowed, [])
assert reuse.restore(reuse.snapshot(failed), [d], "r1") is None