fix(check): 修国动真实投标文件暴露的 6 处(规则集 r5)
国动真实投标文件查标一次(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:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1000,6 +1000,8 @@ COMMERCIAL = re.compile(
|
||||
NOT_CONDITION = re.compile(
|
||||
r"(不|无|未|免|不再|无需|不得|决不|不会)[^。;,]{0,4}(另行|另计|另议)|无效|否则|不予|不接受|免费|不影响|不增加"
|
||||
r"|(不|不得|不会|决不)以任何理由"
|
||||
# 「不以正常接口对接和适配工作另行收费」「不以维护、适配或缺陷修复为由另行收费」:承诺,不是附加条件
|
||||
r"|(不|不得|不会|决不|不再)以[^。;,]{0,24}(另行|另计|另议)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user