"""每条清单项要不要在投标文件里找响应、按什么响应方式判(决策 C12,`docs/architecture/check-eval.md` §2.2 第 ② 项)。 开发集上规矩的投标文件报出的问题,近一半落在不需要投标人回答的条目上(无效情形的列举、日期、评分标准、 采购人自己查询的资格)。这些条目不逐条找响应,写进检查覆盖范围: - 无效情形、评审规定(只有「无效」类别、不是标注出来的条款):由规则检查(第 2 步)和人工核对覆盖; - 日期与递交:由规则检查(落款日期、有效期)和需人工核对覆盖; - 评分标准本身:没写要附什么材料的不找;写了的按证明材料找; - 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找; - 写明没有的要求(无特定资格要求)、总括性的「须具备招标文件规定的资格要求」、 中标或签订合同以后才做的事,不找; - 条件性要求(联合体、残疾人福利性单位 / 监狱企业 / 本国产品的价格扣除):投标文件里有对应的文件、 说明投标人声称适用时才找(国动真实投标文件,check-eval §14)。 其余按类别取招标文件的响应方式:技术参数用技术类,其余用商务类。 """ from __future__ import annotations import re from collections.abc import Iterable from dataclasses import dataclass from typing import Any from bid_doctor.checklist.response_mode import Mode # 要求投标时附证明材料(读标的 evidence_required 常常是空的,按原文判断);中标后 / 签合同时才交的不算 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 mode: str = "explicit" # explicit / implicit skip: str | None = None # 不找响应的原因(SKIP_LABELS 的键) mode_status: str = "found" evidence: bool = False # 要求投标时附证明材料:默认接受也不能免(没附就是没附) def decide(item: dict[str, Any], modes: dict[str, Mode], ctx: Context | None = None) -> Decision: cats = set(item.get("categories") 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") if "qualification" in cats and "tech_param" not in cats and NO_PROOF.search(text): return Decision(False, skip="no_proof") mode = modes.get("technical" if "tech_param" in cats else "commercial") if mode is None: return Decision(True, evidence=evidence) return Decision(True, mode=mode.mode, mode_status=mode.status, evidence=evidence)