feat(check): 响应方式识别(C12)和「这条要不要投标人回答」;评测集纠错

- checklist/response_mode.py:按类别(技术 / 商务)识别招标文件的响应方式(逐条响应 / 默认接受),规则优先;
  两种写法冲突按逐条,认不出按逐条并写进覆盖范围。
- check/applicability.py:无效情形(★ / 斜体下划线标注的条款除外)、日期与递交、没要求附材料的评分标准、
  无需提供证明或由采购人查询的资格,不逐条找响应,写进覆盖范围;要求附证明材料按原文判断,中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记 implicit_accepted、不出问题;实质性要求记 implicit_review、提示级
  人工核对;要附证明材料的不免(迁移 0006)。
- match.py:修同一行单元格又是别的条目候选句时的 KeyError(每条清单项单独记候选句,句子从总表里查)。
- 评测集:注入器同一要求的所有表格行一起改;散文、附件、格式文件里也响应了的要求不挂漏响应类问题;
  错位响应必须是另一个参数;复核判不成立的单元不再挂;regen.py 按固定种子重新生成。

开发集 17 份:规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32
(docs/architecture/check-eval.md §4)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 14:25:14 +08:00
parent b7432643dd
commit cb0c2fcafe
13 changed files with 492 additions and 21 deletions

View File

@@ -0,0 +1,31 @@
"""查标结论加「按招标文件默认接受」(决策 C12)。
- implicit_accepted:一般要求,不出问题;
- implicit_review:实质性要求,提示级人工核对。
Revision ID: 0006
"""
from __future__ import annotations
from alembic import op
revision = "0006"
down_revision = "0005"
VERDICTS = "'responded', 'not_found', 'negative_deviation', 'suspected_unmet', 'no_value', 'undetermined', 'scanned'"
def upgrade() -> None:
op.get_bind().exec_driver_sql(f"""
ALTER TABLE check_item_result DROP CONSTRAINT check_item_result_verdict_check;
ALTER TABLE check_item_result ADD CONSTRAINT check_item_result_verdict_check
CHECK (verdict IN ({VERDICTS}, 'implicit_accepted', 'implicit_review'));
""")
def downgrade() -> None:
op.get_bind().exec_driver_sql(f"""
ALTER TABLE check_item_result DROP CONSTRAINT check_item_result_verdict_check;
ALTER TABLE check_item_result ADD CONSTRAINT check_item_result_verdict_check CHECK (verdict IN ({VERDICTS}));
""")

View File

@@ -0,0 +1,56 @@
"""每条清单项要不要在投标文件里找响应、按什么响应方式判(决策 C12,`docs/architecture/check-eval.md` §2.2 第 ② 项)。
开发集上规矩的投标文件报出的问题,近一半落在不需要投标人回答的条目上(无效情形的列举、日期、评分标准、
采购人自己查询的资格)。这些条目不逐条找响应,写进检查覆盖范围:
- 无效情形、评审规定(只有「无效」类别、不是标注出来的条款):由规则检查(第 2 步)和人工核对覆盖;
- 日期与递交:由规则检查(落款日期、有效期)和需人工核对覆盖;
- 评分标准本身:没写要附什么材料的不找;写了的按证明材料找;
- 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找。
其余按类别取招标文件的响应方式:技术参数用技术类,其余用商务类。
"""
from __future__ import annotations
import re
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}(查询|核查|查验)|信用中国|信用记录")
SKIP_LABELS = {
"condition": "无效情形和评审规定",
"schedule": "日期与递交要求",
"scoring": "评分标准(没要求附证明材料)",
"no_proof": "资格条件(写明无需提供证明,或由采购人查询)",
}
@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]) -> Decision:
cats = set(item.get("categories") or [])
text = f"{item.get('title') or ''}{item.get('requirement_text') or ''}"
if cats == {"invalidity"} and item.get("importance_basis") != "marker":
return Decision(False, skip="condition") # 「……的,投标无效」这类情形;★ / 斜体下划线标注的条款是要求,照常找
if "schedule" in cats and not cats & {"tech_param", "qualification", "scoring"}:
return Decision(False, skip="schedule")
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)

View File

@@ -52,8 +52,14 @@ def spec(result: Result, item: dict[str, Any]) -> Spec | None:
name = str(item.get("title") or "")
base = {"importance_basis": item.get("importance_basis"), **result.detail}
scope = "、".join(result.detail.get("search_scope") or [])
if result.verdict == "responded":
if result.verdict in ("responded", "implicit_accepted"):
return None
if result.verdict == "implicit_review":
text = (
"招标文件规定未列入偏离表的视为接受,投标文件里没有找到对这条要求的明确响应。"
"这是实质性要求,请人工核对所投内容确实满足。"
)
return Spec("manual_review", "hint", f"请人工核对:{name}", text, {**base, "mode": "implicit"})
if result.verdict == "not_found" and result.evidence_status == "unverified":
claimed = result.detail.get("claimed_pages") or []
where = f"{_pages(claimed)}有响应" if claimed else "有响应"

View File

@@ -74,6 +74,18 @@ class Stats:
self.verify_failures[code] = self.verify_failures.get(code, 0) + 1
def apply_modes(results: dict[UUID, Result], items: dict[UUID, dict[str, Any]], modes: dict[UUID, Any]) -> None:
"""按招标文件的响应方式改结论(决策 C12):默认接受(implicit)的要求没找到响应不算问题,记「按招标文件默认接受」;
实质性要求出提示级「请人工核对」,不出致命。找到了不满足的响应照常报。modes:清单项 → applicability.Decision。"""
for item_id, d in modes.items():
r = results.get(item_id)
if r is None or d.mode != "implicit" or r.verdict != "not_found" or d.evidence:
continue # 要附证明材料的要求,默认接受也免不了
r.verdict = "implicit_review" if items[item_id].get("importance") == "substantive" else "implicit_accepted"
r.evidence_status, r.reason_code = "none", None
r.detail = {**r.detail, "mode": "implicit"}
def short_sid(doc: BidDoc, s: Sentence) -> str:
return f"d{doc.no}p{s.page_no}s{s.seq}"

View File

@@ -20,16 +20,18 @@ 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 findings, match
from bid_doctor.check import applicability, findings, match
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
from bid_doctor.checklist import response_mode
from bid_doctor.checklist import service as checklist
from bid_doctor.core import events
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.document import citations
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
@@ -136,6 +138,23 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
snapshot = await checklist_edit.version_items(s, r.checklist_version_id)
lots = await projects.lot_labels(s, r.project_id)
pending = (await checklist.pending_counts(s, [r.project_id])).get(r.project_id, 0)
tenders = (
await s.execute(
select(SourceDocument).where(
SourceDocument.project_id == r.project_id,
SourceDocument.role.in_(("tender", "addendum", "clarification")),
SourceDocument.text_layer_key.is_not(None),
)
)
).scalars()
tender_sents = [
x
for d in tenders
for x in text_layer.body_sentences(
[text_layer.Page.from_json(pg) for pg in await documents.text_layer(s, ctx, d)]
)
]
modes = response_mode.detect(tender_sents) # 招标文件按类别的响应方式(C12)
bidding = set(r.lot_ids)
items, other_lot = {}, 0
for item_id, payload in snapshot.items():
@@ -144,6 +163,12 @@ 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()}
skipped: dict[str, int] = {}
for d in decisions.values():
if not d.match and d.skip:
skipped[d.skip] = skipped.get(d.skip, 0) + 1
items = {i: p for i, p in items.items() if decisions[i].match} # 不需要投标人回答的条目不逐条找响应
last = [PROGRESS["match"]]
async def progress(ratio: float) -> None:
@@ -153,6 +178,7 @@ 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)
match.apply_modes(results, items, decisions)
await _stage(ctx, run_id, "rules") # 第 2 步:报价形式、形式要件、需人工核对
await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点
@@ -174,7 +200,7 @@ 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)
r.coverage = _coverage(r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes)
await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"])
@@ -352,6 +378,8 @@ def _coverage(
other_lot: int,
stats: match.Stats,
tokens: Any,
skipped: dict[str, int],
modes: dict[str, response_mode.Mode],
) -> dict[str, Any]:
"""检查覆盖范围:报告首页、查标页顶部都读这里(FR-M3-09、m3-plan §2.5)。"""
undetermined = sum(x.verdict == "undetermined" for x in results.values())
@@ -367,6 +395,19 @@ def _coverage(
not_checked.append(f"{undetermined} 条没有完成判断(模型漏答或暂时不可用),已列为「请人工核对」")
if scanned:
not_checked.append(f"{scanned} 页没有文字层(扫描件),这些页上的内容没有检查")
for reason, n in skipped.items():
not_checked.append(f"{applicability.SKIP_LABELS[reason]} {n} 条:不在投标文件里逐条找响应")
labels = {"technical": "技术要求", "commercial": "商务要求"}
for m in modes.values():
if m.status == "unknown":
not_checked.append(f"招标文件没有写明{labels[m.category]}是否要求逐条响应,按逐条响应处理")
elif m.status == "conflict":
not_checked.append(
f"招标文件对{labels[m.category]}的响应方式写法不一致(既要求逐条响应,又写明未列出的视为接受),按逐条响应处理"
)
implicit = sum(x.verdict in ("implicit_accepted", "implicit_review") for x in results.values())
if implicit:
checked.append(f"其中 {implicit} 条按招标文件「未列入偏离的视为接受」处理,没有复述不算问题")
not_checked.append("报价形式、形式要件、资质匹配:规则检查还没有上线")
if not r.checklist_confirmed:
not_checked.append("清单未确认,检查结果以当前清单为准")
@@ -383,6 +424,16 @@ def _coverage(
"evidence_unverified": unverified,
},
"checklist_confirmed": r.checklist_confirmed,
"skipped": skipped,
"response_modes": [
{
"category": m.category,
"mode": m.mode,
"status": m.status,
"basis": [{"page": x.page_no, "text": x.text[:120]} for x in (m.implicit + m.explicit)[:4]],
}
for m in modes.values()
],
"legal_regime": {"value": r.legal_regime, "rules_verified": r.legal_regime in REGIMES_VERIFIED},
"rule_set_version": RULE_SET_VERSION,
"prompt_versions": r.prompt_versions,

View File

@@ -0,0 +1,63 @@
"""招标文件对投标人怎么响应的规定(决策 C12,`evals/check/GUIDELINES.md` §2):按类别(技术 / 商务)识别响应方式。
- explicit 逐条响应:「逐条 / 逐项作出响应」「逐一逐条响应填写」「逐条标明满足与否」;
- implicit 默认接受:「否则将认为投标人接受」「未列入上表的视作完全接受」「除所列偏离外均视作理解和响应」
「其余无偏离内容无需赘述」;
- 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。
规则优先、纯函数;结果带原文句子,查标写进检查覆盖范围。
TODO(C12):存进清单、在清单页显示并可改(人工确认点);模型兜底规则没认出的写法。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from bid_doctor.document.pipeline.text_layer import Sentence
EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)")
IMPLICIT = re.compile(
r"(否则|未列入|未列出|未列明|除[^。;]{0,30}外)[^。;]{0,25}(视为|视作|认为)[^。;]{0,15}(接受|响应|理解)"
r"|无需赘述"
)
TABLE_HINT = re.compile(r"(技术|商务)[^。;]{0,12}(偏离|响应)")
LOOKBACK = 12 # 往前找几句,看这句说明属于哪张表(技术 / 商务)
CATEGORIES = ("technical", "commercial")
@dataclass(slots=True)
class Mode:
category: str # technical / commercial
mode: str # explicit / implicit
status: str # found / conflict / unknown
explicit: list[Sentence] = field(default_factory=list[Sentence])
implicit: list[Sentence] = field(default_factory=list[Sentence])
def _scope(i: int, sentences: list[Sentence]) -> set[str]:
"""这句说明管哪一类:句子自己写了「技术 / 商务」就按它,否则看前面几句里最近的表名;都没有就两类都管。"""
own = {c for c, k in (("technical", "技术"), ("commercial", "商务")) if k in sentences[i].text}
if own:
return own
for s in reversed(sentences[max(0, i - LOOKBACK) : i]):
if m := TABLE_HINT.search(s.text):
return {"technical" if m.group(1) == "技术" else "commercial"}
return set(CATEGORIES)
def detect(sentences: list[Sentence]) -> dict[str, Mode]:
hits: dict[str, Mode] = {c: Mode(c, "explicit", "unknown") for c in CATEGORIES}
for i, s in enumerate(sentences):
exp, imp = bool(EXPLICIT.search(s.text)), bool(IMPLICIT.search(s.text))
if not exp and not imp:
continue
for c in _scope(i, sentences):
(hits[c].implicit if imp else hits[c].explicit).append(s)
for m in hits.values():
if m.explicit and m.implicit:
m.mode, m.status = "explicit", "conflict"
elif m.implicit:
m.mode, m.status = "implicit", "found"
elif m.explicit:
m.mode, m.status = "explicit", "found"
return hits

View File

@@ -77,3 +77,57 @@ def test_no_value_is_a_hint_and_unverified_is_explained() -> None:
)
assert uv is not None and uv.severity == "fatal" and "第 3 页" in uv.explanation and "没能核对" in uv.explanation
assert findings.spec(match.Result("responded", "model", "verified"), item) is None
def test_response_mode_by_category() -> None:
from bid_doctor.checklist import response_mode
sents = [
sent(1, "技术规格偏离表"),
sent(2, "投标人应将所有偏离的条目列入上表,未列入上表的视作投标人完全接受。"),
sent(3, "商务条款偏离表"),
sent(4, "投标人须对商务要求逐条作出响应。"),
]
m = response_mode.detect(sents)
assert (m["technical"].mode, m["technical"].status) == ("implicit", "found")
assert (m["commercial"].mode, m["commercial"].status) == ("explicit", "found")
both = response_mode.detect([sent(1, "须逐项作出响应。"), sent(2, "否则将认为投标人接受招标文件的要求。")])
assert (both["technical"].mode, both["technical"].status) == ("explicit", "conflict") # 写法冲突按逐条(C12)
none = response_mode.detect([sent(1, "本项目为货物采购。")])
assert (none["commercial"].mode, none["commercial"].status) == ("explicit", "unknown")
def test_applicability_skips_items_that_need_no_response() -> None:
from bid_doctor.check import applicability
from bid_doctor.checklist import response_mode
modes = response_mode.detect([sent(1, "未列入偏离表的技术要求视作投标人完全接受。")])
decide = applicability.decide
assert decide({"categories": ["invalidity"], "importance_basis": "tender_text"}, modes).skip == "condition"
assert decide({"categories": ["invalidity"], "importance_basis": "marker"}, modes).match # ★ 条款是要求
assert decide({"categories": ["schedule"]}, modes).skip == "schedule"
assert decide({"categories": ["scoring"]}, modes).skip == "scoring"
assert decide({"categories": ["scoring"], "evidence_required": "业绩合同"}, modes).match
assert decide({"categories": ["scoring"], "title": "业绩(2 分):提供近三年类似项目合同复印件"}, modes).match
assert (
decide({"categories": ["scoring"], "title": "提供检测报告,中标后签订合同前提供原件"}, modes).skip == "scoring"
)
assert decide({"categories": ["tech_param"], "title": "支持硬件BFD,提供官网截图证明"}, modes).evidence
assert decide({"categories": ["qualification"], "title": "未被列入信用中国失信名单"}, modes).skip == "no_proof"
d = decide({"categories": ["tech_param"], "title": "内存"}, modes)
assert d.match and d.mode == "implicit"
def test_implicit_not_found_is_accepted_but_substantive_gets_a_hint() -> None:
from uuid import uuid4
from bid_doctor.check import applicability
a, b = uuid4(), uuid4()
results = {a: match.Result("not_found", "model"), b: match.Result("not_found", "model")}
items = {a: {"importance": "normal"}, b: {"importance": "substantive", "title": "★双电源"}}
decisions = {a: applicability.Decision(True, mode="implicit"), b: applicability.Decision(True, mode="implicit")}
match.apply_modes(results, items, decisions)
assert results[a].verdict == "implicit_accepted" and findings.spec(results[a], items[a]) is None
spec = findings.spec(results[b], items[b])
assert results[b].verdict == "implicit_review" and spec is not None and spec.severity == "hint"