From 902a130c82cb52ed31043d4eff4dabe1e96def40 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Wed, 4 Nov 2026 20:34:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(check):=20=E4=BF=AE=E7=95=99=E5=87=BA?= =?UTF-8?q?=E9=9B=86=E6=9A=B4=E9=9C=B2=E7=9A=84=205=20=E5=A4=84=EF=BC=88?= =?UTF-8?q?=E8=A7=84=E5=88=99=E9=9B=86=202026.11-r3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 响应方式:「逐条响应」要说的是要求 / 参数 / 偏离,声明函填写说明不算;「否则视为无效响应」不算默认接受 - 偏离栏按「是不是明确满足」判断:偏离栏、响应情况栏一起看,其他短写法请人核对(最高重要);表体序号认「技-86」等 - 另报价、附加条件按结构判断(另一个不同的总价金额、保留 / 另行 / 调整报价等) - 空白栏:「精确到年月日」不算没填 - 比对时折叠异体字(戶→户等),原文照存 - 记录决策 E2:先修再跑国动真实投标文件 开发集召回 75%、规矩投标文件问题数不变;留出集参考数字见 check-eval §12(已看过,仅参考)。 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- docs/architecture/check-eval.md | 25 ++- docs/decisions.md | 6 + server/src/bid_doctor/check/deviation.py | 164 ++++++++++++++++-- server/src/bid_doctor/check/findings.py | 6 + server/src/bid_doctor/check/retrieval.py | 3 +- server/src/bid_doctor/check/rules.py | 113 +++++++----- server/src/bid_doctor/check/service.py | 2 +- .../src/bid_doctor/checklist/response_mode.py | 12 +- .../document/pipeline/text_layer.py | 16 +- server/tests/test_check_flow.py | 2 +- server/tests/test_check_rules.py | 99 +++++++++++ server/tests/test_check_units.py | 55 +++++- 13 files changed, 436 insertions(+), 70 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 269450f..1eaea5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,4 +218,5 @@ samples/ - 2026-11-04:查标第五轮(不调模型的后处理):偏离栏直读(证据所在表格行的偏离格写着不满足就改判负偏离)、响应错位(照抄要求格 + 响应格写的是别的内容);复查复用(送审内容没变的条目沿用上次的模型结论)和每次检查的硬预算。开发集 17 份召回 61% → 73%,负偏离未说明 64% → 90%,致命级 22 → 24 / 32,规矩投标文件的问题数不变(235)。见 `docs/architecture/check-eval.md` §8。 - 2026-11-04:查标第六轮:送给模型的「同一行」按单元格分行;修了「以合同签订时间为准」被当成中标后才交、业绩评分项被跳过的缺陷;重复条目按用户决定改为各自判断、只合并显示(C5 修订,`docs/decisions.md`);致命项重问按数据(开发集致命级证据校验失败 0 条)暂不做。评测集裁定 1 条。开发集召回 74%、致命级 25 / 32。见 `docs/architecture/check-eval.md` §9。 - 2026-11-04:查标第 3 步冻结(`a60d16e`),跑留出集一次:S03、S06、S09 各一份风格 B 规矩 + 风格 C 马虎(风格 C 由封存一侧的会话实现),96 个注入问题 Fable 复核通过 79,用户抽查 19 条全部同意。留出集召回 54%(开发集 74%)、致命级 56%(开发集 78%)、负偏离未说明 25%(开发集 92%)、规则问题 13 / 20;S03 的规矩投标文件因响应方式识别过度触发报了 1094 个提示,人工确认响应方式后降到 45 个。开发集分数有相当一部分是调出来的;要修的 5 处见 `docs/architecture/check-eval.md` §11。 -- 下一步:用冻结代码跑一次国动真实投标文件(等用户提供),然后修 §11 列的 5 处。 +- 2026-11-04:按用户决定(E2)先修留出集暴露的 5 处(响应方式识别、偏离栏按「是不是明确满足」判断、另报价 / 附加条件按结构判断、空白栏、异体字),规则集 2026.11-r3。开发集不退化(召回 75%、规矩投标文件问题数不变);留出集参考数字召回 54% → 69%、规矩投标文件问题 1134 → 82(已看过,正式成绩仍以冻结代码那次为准)。见 `docs/architecture/check-eval.md` §12。 +- 下一步:跑国动真实投标文件(等用户提供,用修好的代码)。 diff --git a/docs/architecture/check-eval.md b/docs/architecture/check-eval.md index 216597f..200d8b2 100644 --- a/docs/architecture/check-eval.md +++ b/docs/architecture/check-eval.md @@ -253,7 +253,30 @@ 这些修完以后,留出集已经测不出修复效果(看过了),报告里的留出集数字以本节为准。国动真实投标文件仍然用冻结代码跑一次,之后再修。 -## 12. 下一步 +## 12. 修留出集暴露的 5 处(2026-11-04,用户决定先修再跑真实文件,决策 E2) + +规则集 `2026.11-r3`。 + +1. **响应方式识别**(`checklist/response_mode.py`):「逐条响应」那句话要说的是要求、条款、参数、偏离、采购需求,声明函填写说明里的「逐一、准确填写……」(10 份招标文件里 7 份都有这句套话)不算;「默认接受」的写法里,「视为」后面紧跟「无效 / 不 / 未 / 非」的(「否则视为无效响应」)是不响应的后果,不算。10 份招标文件的识别结果和档案一致(S03 → 默认接受,S09 → 逐条),S05 商务仍是「没写明」(原文确实没写)。 +2. **偏离栏按「是不是明确满足」判断**(`check/deviation.py`):按表头认出偏离栏和响应情况栏,两栏合起来判断——任一栏明确写了不满足(短的一段里有「负偏离 / 部分响应 / 不满足」,长的一段开头就是这些)就是负偏离;偏离栏写的不是「无偏离 / 满足 / 符合」的其他短写法(「不完全一致 / 接近要求 / 基本一致」)请人核对,级别最高到重要;「响应情况」栏写的常是响应内容(「支持」「3 年」),只认明确的不满足。窄栏折成两段的「无偏」「离」合起来认。认不出表头时退回原来的整行明确写法。响应表规则用同一套判断,表体序号认「技-86」「C-3」「(一)」这类写法(原来只认纯数字,风格 B / C 的表整张被跳过)。 +3. **另报价、附加条件按结构判断**(`check/rules.py`):另报价看「报价字样或备选 / 优惠 / 让利 / 如……调整为,加上一个和投标总价不同、不低于总价三成的金额」(金额在前在后都算),「备选 / 替代 / 方案二 / 第二套」致命,其余重要;业绩合同金额、限价、保证金不算。附加条件补「保留……权利」「另行签订」「以另行 / 实际 / 届时……为准」「调整报价」「价格波动」,商务语境补「交付 / 供货 / 服务期 / 售后 / 协议」,「不以任何理由……」算承诺。 +4. **空白栏**:「精确到 / 格式为 / 显示」后面的「年月日」、后面跟「时」的不算没填。 +5. **异体字**:比对时把「戶 / 戸 → 户」这类异体码位和 CJK 兼容汉字折叠(`text_layer.norm`、检索分词),原文照存。 + +每处都有开发集、留出集以外写法的单元测试;中间一版偏离栏判断把「投标响应」栏的响应内容和折行碎片当成了要核对的值,开发集上冒出几十个误报,改成两栏分开判断、按段判断后回落。 + +| 指标 | 开发集 修前 → 修后 | 留出集 冻结代码 → 修后(**已看过,仅参考**) | +|---|---|---| +| 查标召回 | 111 / 149(74%)→ 112(75%) | 40 / 74(54%)→ 51(69%) | +| 致命级召回 | 25 / 32 → 25 / 32 | 23 / 41 → 26 / 41 | +| D06 负偏离未说明 | 92% → 93% | 25% → 80% | +| 规矩的投标文件报出的问题 | 239 → 239 | 1134 → 82 | +| 规则问题查出 | 57 / 57 → 57 / 57 | 13 / 20 → 19 / 20 | +| 规则问题误报 | 0 → 4(规矩的投标文件上 1,都是「没读到响应情况」的重要级提示) | 3 → 0 | + +留出集的正式成绩仍以 §11(冻结代码)为准:修的时候看过留出集的漏报,修后的数字有过拟合成分。 + +## 13. 下一步 1. 分项报价表和采购需求清单逐项对照(P13)**暂缓**:在开发集 7 份招标文件上试读「名称 + 数量」的采购清单表,S02、S08(服务类)没有这种表;S04 把「是否进口」一栏读成了名称;S10 采购包 2 的表跨页、第二页没有表头,漏了大半;S01 9 行里能和投标文件的分项对上名称的只有 7 行。P13 是致命级,按这个抽取质量在真实文件上会大量误报「缺项」,评测集里也没有注入过,先不上,覆盖范围里照写「还没有上线」。散文写法的单价上限同样暂缓。 2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。 diff --git a/docs/decisions.md b/docs/decisions.md index cd29626..3c3318c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4,6 +4,12 @@ --- +## E2 · 2026-11-04 · 先修留出集暴露的 5 处,国动真实投标文件之后再跑 + +- **决策**:留出集跑完后(`check-eval.md` §11),先修暴露的 5 处(响应方式识别过度触发、偏离栏只认开发集写法、附加条件 / 另报价按措辞枚举、「精确到年月日」误报、PDF 异体字),国动真实投标文件用修好以后的代码跑,而不是冻结代码。 +- **理由**:用户决定。真实文件晚一点提供;先修明确的缺陷,真实文件上测到的是改进后的水平。 +- **影响**:国动真实投标文件不再是「冻结代码」的成绩;留出集的正式成绩以冻结代码那一次为准(修复后留出集上的数字只作参考,因为修的时候已经看过留出集的漏报)。 + ## C5(修订)· 2026-11-04 · 重复条目不合并判断,只合并显示 | # | 决策 | 理由 | diff --git a/server/src/bid_doctor/check/deviation.py b/server/src/bid_doctor/check/deviation.py index d57ca6a..22e4201 100644 --- a/server/src/bid_doctor/check/deviation.py +++ b/server/src/bid_doctor/check/deviation.py @@ -1,9 +1,11 @@ -"""偏离栏直读(m3-plan §2.1「偏离表直取」,check-eval §8):模型判「已响应」的条目,看它引的证据句所在的表格行, -同一行有单元格写着「偏离 / 负偏离 / 部分满足 / 未完全响应」这类字样的,改判负偏离。 +"""偏离栏直读(m3-plan §2.1「偏离表直取」,check-eval §8、§11):模型判「已响应」的条目,看它引的证据句所在的表格行, +偏离栏(按表头认出那一栏)写的不是明确的「无偏离 / 满足」,就改判负偏离。 -开发集上负偏离未说明(D06)漏掉的,多数是响应格照抄了要求(「满足,……」)、偏离格写着「偏离」, -模型只看了响应格——提示词已经要求读整行,模型还是会漏,这里用确定的规则补上。 -单元格要整格是这些字样才算(「如不满足……」这类正文不算);改判后证据带上那一格,出处可回溯。 +- 偏离栏的值按「是不是明确满足」判断,不靠枚举不满足的说法:留出集写「不完全一致 / 与要求有出入 / 接近要求」, + 开发集的枚举一条都没认出(D06 92% → 25%)。明确写了不满足的(负偏离、部分满足……)照级别报;其余短写法 + (不超过 12 个字)请人核对,级别最高到「重要」;长段文字(偏离说明)不在这里判; +- 认不出表头时退回整行找「负偏离 / 部分满足」这类明确写法; +- 改判后证据带上那一格,出处可回溯。 """ from __future__ import annotations @@ -19,6 +21,85 @@ NEGATIVE = re.compile( r"^(未完全响应|不完全响应|部分响应|未响应|不响应|负偏离|有偏离|偏离|不满足|部分满足|未满足|不符合|部分符合)" r"([((].*)?[。;]?$" ) +CLEAR_OK = re.compile( + r"^(无|无偏离|没有偏离|不偏离|正偏离|优于要求|优于|完全响应|完全满足|满足|响应|已响应|符合|完全符合|一致|完全一致" + r"|无差异|—+|-+|/|/)([((].*)?[。;]?$" +) +DEV_HEAD = re.compile(r"偏离|响应情况|是否响应|响应结论|是否满足|满足情况|符合情况") +YES_NO_HEAD = re.compile(r"是否(响应|满足|符合)") +DEVIATE_HEAD = re.compile(r"是否偏离") +SHORT = 12 +# 表体每行开头的序号:「12」「技-86」「C-3」「(一)」(风格 B / C 和不少真实文件用自己的编号) +SERIAL = re.compile(r"(?:[A-Za-z一-鿿]{1,2}[--])?\d{1,4}[.、]?|[((]?[一二三四五六七八九十]{1,3}[))、.]?") + + +def classify(text: str, head: str = "") -> str: + """偏离 / 响应栏的一格:ok 明确满足、negative 明确不满足、unclear 其他短写法(请人核对)、other 长段说明或空。 + head 是这一栏的表头:「是否偏离」栏写「是」是偏离,「是否满足」栏写「否」是不满足。""" + t = norm(text) + if not t: + return "other" + if DEVIATE_HEAD.search(head) and t in ("是", "是。"): + return "negative" + if YES_NO_HEAD.search(head) and t in ("否", "否。", "×"): + return "negative" + if (YES_NO_HEAD.search(head) and t in ("是", "√")) or (DEVIATE_HEAD.search(head) and t in ("否", "否。")): + return "ok" + if NEGATIVE.match(t): + return "negative" + if CLEAR_OK.match(t): + return "ok" + return "unclear" if len(t) <= SHORT else "other" + + +def is_dev_head(text: str) -> bool: + t = norm(text) + return ( + len(t) <= 30 and bool(DEV_HEAD.search(t)) and "说明" not in t + ) # 「投标文件中的响应情况(完全响应/未完全响应)」 + + +def judge_row(columns: list[tuple[list[Sentence], str]]) -> tuple[str, list[Sentence]]: + """一行里几栏(偏离栏、响应情况栏)合起来的结论:任一栏明确不满足就是 negative(「投标响应情况」写「部分响应」、 + 偏离栏却写「符合」,开发集 S08-B);否则偏离栏是 unclear 就是 unclear;返回结论和依据的那几段。""" + kinds = [(judge(cells, head), cells) for cells, head in columns if cells] + for want in ("negative", "unclear"): + hit = next((cells for k, cells in kinds if k == want), None) + if hit is not None: + return want, hit + return "ok" if kinds else "other", [] + + +NEG_WORDS = re.compile(r"负偏离|部分(响应|满足|符合)|未完全|不完全|不满足|未满足|不符合|未响应|不响应|有偏离") +NEG_START = re.compile(r"^(" + NEG_WORDS.pattern + ")") +# 「基本一致 / 大致符合 / 接近满足」不是明确满足,要请人核对 +OK_WORDS = re.compile(r"(? str: + """一行里这一栏的几段合起来判断。窄栏会把「无偏离」折成「无偏」「离」,无框线的表还可能把相邻行的 + 同一栏拼进来,所以按字样判断而不是整格匹配:有明确不满足的字样就是 negative;否则有「无偏 / 满足 / + 符合」就是 ok;一个字的碎片不判。只有「偏离」栏才把其他短写法算作 unclear;「响应情况」这类栏写的 + 常是响应内容(「支持」「3年」),只认明确的不满足。""" + pieces = [t for c in cells if (t := norm(c.text))] + joined = "".join(pieces) + if not joined: + return "other" + if joined in ("偏离", "偏离。") and not all(c.cell for c in cells): + return "other" # 跨页、无框线的行抽字时「无偏离」会被拆成「无」和「偏离」(negative_cell 同一道限制) + # 按段判断:短的一段里有不满足的字样,或长的一段开头就是不满足(「满足,……不符合……时预警」是正常响应) + if any((len(t) <= SHORT and NEG_WORDS.search(t)) or NEG_START.match(t) for t in pieces): + return "negative" + by_head = classify(joined, head) + if by_head in ("negative", "ok") and (DEVIATE_HEAD.search(head) or YES_NO_HEAD.search(head)): + return by_head + if OK_WORDS.search(joined): + return "ok" + if len(joined) <= 1: + return "other" + if by_head == "unclear" and "偏离" not in head: + return "other" + return by_head def negative(text: str) -> bool: @@ -32,30 +113,79 @@ def negative_cell(s: Sentence) -> bool: return negative(t) and (t not in ("偏离", "偏离。") or s.cell is not None) +class Grid: + """投标文件按页分好的行:找一格所在的行,往上(同一页,再看上一页)找表头里的偏离栏。""" + + def __init__(self, docs: list[BidDoc]) -> None: + self.pages: dict[tuple[UUID, int], list[list[Sentence]]] = {} + self.where: dict[int, tuple[UUID, int, int]] = {} + for d in docs: + for no, page in layout.pages(d.sentences).items(): + rows = layout.rows(page) + self.pages[(d.id, no)] = rows + for i, row in enumerate(rows): + for c in row: + self.where[id(c)] = (d.id, no, i) + + def row(self, s: Sentence) -> list[Sentence]: + at = self.where.get(id(s)) + return self.pages[(at[0], at[1])][at[2]] if at else [] + + def dev_columns(self, s: Sentence) -> list[tuple[list[Sentence], str]] | None: + """这一行的偏离栏、响应情况栏:[(这一栏的几段, 表头文字)];找不到表头返回 None。""" + at = self.where.get(id(s)) + if at is None: + return None + doc_id, no, i = at + above = self.pages[(doc_id, no)][:i][::-1] + self.pages.get((doc_id, no - 1), [])[::-1] + for head in above: + if head and SERIAL.fullmatch(norm(head[0].text)): + continue # 表体行(要求栏里也可能写着「……的响应情况」) + heads = [(n, c) for n, c in enumerate(head) if c.cell and is_dev_head(c.text)] + if not heads: + continue + cols = {f"c{n}": layout.span(c) for n, c in enumerate(head)} + row = self.pages[(doc_id, no)][i] + return [ + ([c for c in row if c is not s and layout.column(c, cols) == f"c{n}"], norm(h.text)) for n, h in heads + ] + return None + + def direct_read(results: dict[UUID, Result], docs: list[BidDoc]) -> int: """就地改判,返回改判的条数。""" - rows: dict[int, list[Sentence]] = {} - for d in docs: - for page in layout.pages(d.sentences).values(): - for row in layout.rows(page): - for c in row: - rows[id(c)] = row + grid = Grid(docs) changed = 0 for r in results.values(): if r.verdict not in ("responded", "no_value") or not r.evidence: continue - hit: tuple[BidDoc, Sentence] | None = None + hit: tuple[BidDoc, Sentence, str, str] | None = None for d, s in r.evidence: - cell = next((c for c in rows.get(id(s), []) if c is not s and negative_cell(c)), None) + if not s.cell: + continue # 正文里的证据句:没有偏离栏 + columns = grid.dev_columns(s) + if columns is not None: + kind, cells = judge_row(columns) + if kind in ("negative", "unclear"): + hit = (d, cells[0], kind, norm("".join(c.text for c in cells))) + break + continue + cell = next((c for c in grid.row(s) if c is not s and negative_cell(c)), None) # 认不出表头 if cell is not None: - hit = (d, cell) + hit = (d, cell, "negative", norm(cell.text)) break if hit is None: continue + d, cell, kind, text = hit before, r.verdict = r.verdict, "negative_deviation" - if not any(x is hit[1] for _, x in r.evidence): - r.evidence = [*r.evidence, hit][-3:] - r.detail = {**r.detail, "direct_read": norm(hit[1].text), "model_verdict": before} + if not any(x is cell for _, x in r.evidence): + r.evidence = [*r.evidence, (d, cell)][-3:] + r.detail = { + **r.detail, + "direct_read": text, + "model_verdict": before, + **({"deviation_unclear": True} if kind == "unclear" else {}), + } changed += 1 return changed diff --git a/server/src/bid_doctor/check/findings.py b/server/src/bid_doctor/check/findings.py index 9698a7d..c84af01 100644 --- a/server/src/bid_doctor/check/findings.py +++ b/server/src/bid_doctor/check/findings.py @@ -77,6 +77,12 @@ def spec(result: Result, item: dict[str, Any]) -> Spec | None: if result.verdict == "not_found": text = f"在投标文件({scope})中没有找到对这条要求的响应。" if scope else "没有找到对这条要求的响应。" return Spec("unresponded", sev, f"未找到响应:{name}", text, base) + if result.verdict == "negative_deviation" and result.detail.get("deviation_unclear"): + text = ( + f"这一行的偏离栏写的是「{result.detail.get('direct_read')}」,不是明确的「无偏离 / 满足」," + "请核对是否满足这条要求。" + ) + return Spec("negative_deviation", "important" if sev == "fatal" else sev, f"偏离栏需核对:{name}", text, base) if result.verdict == "negative_deviation": cell = result.detail.get("direct_read") text = ( diff --git a/server/src/bid_doctor/check/retrieval.py b/server/src/bid_doctor/check/retrieval.py index 0374b73..5f70837 100644 --- a/server/src/bid_doctor/check/retrieval.py +++ b/server/src/bid_doctor/check/retrieval.py @@ -12,7 +12,7 @@ import re from collections import Counter from dataclasses import dataclass -from bid_doctor.document.pipeline.text_layer import Sentence +from bid_doctor.document.pipeline.text_layer import Sentence, fold NUM_UNIT = re.compile(r"(\d+(?:\.\d+)?)\s*([A-Za-z]+|[个台套项条次人天年月日分秒毫兆核位路口])?") LATIN = re.compile(r"[A-Za-z][A-Za-z0-9\-]*") @@ -21,6 +21,7 @@ K1, B = 1.5, 0.75 def tokens(text: str) -> list[str]: + text = fold(text) out = [(n + (u or "")).lower() for n, u in NUM_UNIT.findall(text)] out += [w.lower() for w in LATIN.findall(text)] for run in CJK.findall(text): diff --git a/server/src/bid_doctor/check/rules.py b/server/src/bid_doctor/check/rules.py index ca82719..caf0b9c 100644 --- a/server/src/bid_doctor/check/rules.py +++ b/server/src/bid_doctor/check/rules.py @@ -553,9 +553,14 @@ def _lower_near( ALT = re.compile( - r"(备选|替代|选择性|可选)(方案)?(报价|价格|总价)|优惠后?的?(报价|价格|总价|价)|折扣后?的?(报价|价格|总价|价)|" - r"方案[一二三四五1-5][^,。;]{0,6}(报价|总价|价格)|第[二三][种套个份]?(报价|方案)|(让利|下浮|降价)后?的?(报价|价格|总价)" + r"(备选|替代|选择性|可选)[^,。;]{0,6}(报价|价格|总价)|(优惠|折扣|让利|下浮|降价|最终)[^,。;]{0,4}(报价|价格|总价|价)|" + r"方案[一二三四五1-5][^,。;]{0,6}(报价|总价|价格)|第[二三][种套个份]?(报价|方案)" ) +# 附条件的价格:「如……,可将总报价调整为 × 元」 +CONDITIONAL = re.compile(r"(如|若|如果|倘若|假如)[^。;]{0,40}(报价|总价|价格)[^。;]{0,8}(调整|降|下调|变更|改|调)为") +PRICE_WORD = re.compile(r"总报价|投标报价|投标总价|响应报价|磋商报价|报价|总价") +NOT_PRICE = re.compile(r"合同金额|合同价|中标金额|成交金额|业绩|注册资本|营业收入|资产总额|预算|限价|保证金|单价|分项") +OTHER_SHARE = Decimal("0.3") # 另一个价格至少是投标总价的三成,才可能是「另一个报价」 ALT_FATAL = re.compile(r"备选|替代|选择性|可选|方案[二三四五2-5]|第[二三]") NEG = re.compile(r"不|无|未|拒绝") RESP_PREFIX = re.compile(r"^(完全响应|已响应|响应|满足|无偏离|正偏离|同意|接受)[,,::。、\s]*") @@ -582,20 +587,27 @@ class Tender: def check_quotes( docs: list[BidDoc], tender: Tender, tables: list[PriceTable], totals: list[Total], hits: list[Hit] ) -> None: - """另外给的报价:「备选方案报价」「方案二总价」后面跟着金额(致命);「优惠价 / 让利后」跟着的金额和投标总价 - 不一样的(重要:多半是另报了一个价,也可能只是说明)。金额等于投标总价(开标一览表或分项合计)的、否定句、 - 招标文件原话都不算。""" + """另外给的报价,按结构判断(check-eval §11:按措辞枚举在留出集上一条都没认出):一句话里有报价字样或「备选 / + 优惠 / 让利」「如……调整为」这类说法,又有一个和投标总价(开标一览表或分项合计)不同、且不低于总价三成的金额 + (金额在前在后都算)。「备选 / 替代 / 方案二 / 第二套」按致命报,其余按重要报(也可能只是说明,请人核对)。 + 否定句、招标文件原话、业绩合同金额、限价、保证金这类不算。""" values = {t.value for t in totals} | {t.total[1] for t in tables if t.total} + ref = max(values) if values else None for d in docs: for s in d.sentences: - m = ALT.search(s.text) - if not m: + amounts = money(s.text) + if not amounts or tender.restated(s.text): continue - amounts = money(s.text[m.end() :]) - clause = re.split(r"[,。;,;]", s.text[: m.start()])[-1] - if not amounts or NEG.search(clause) or tender.restated(s.text) or set(amounts) & values: + m = ALT.search(s.text) or CONDITIONAL.search(s.text) + priced = PRICE_WORD.search(s.text) and not NOT_PRICE.search(s.text) + if not (m or priced): continue - fatal = bool(ALT_FATAL.search(m.group(0))) + if m and NEG.search(re.split(r"[,。;,;]", s.text[: m.start()])[-1]): + continue + other = [a for a in amounts if a not in values and (a >= ref * OTHER_SHARE if ref else a >= 10_000)] + if not other: + continue + fatal = bool(m and ALT_FATAL.search(m.group(0))) hits.append( Hit( "PRICE.MULTIPLE_QUOTES", @@ -746,7 +758,14 @@ def _filled(text: str) -> list[tuple[str, str]]: BLANKS = ( (re.compile(r"[__]{3,}"), "下划线处没有填"), - (re.compile(r"(? Non ) -RESP_HEAD = re.compile(r"(响应情况|是否响应|响应结论|偏离情况|是否偏离|偏离/响应|响应/偏离|是否满足|符合或偏离)") -NEG_RESP = re.compile( - r"^(未完全响应|不完全响应|部分响应|未响应|不响应|负偏离|有偏离|不满足|部分满足|未满足|不符合|部分符合)" - r"([((].*)?[。;]?$" -) -YES_NO_HEAD = re.compile(r"是否(响应|满足|符合)") -DEVIATE_HEAD = re.compile(r"是否偏离") FATAL_TABLE = re.compile(r"(符合性|实质性|资格)") -SERIAL = re.compile(r"\d{1,3}") - - -def _negative(cell: str, head: str) -> bool: - t = norm(cell) - if NEG_RESP.match(t) or deviation.negative(t): - return True - if YES_NO_HEAD.search(head) and t in ("否", "×"): - return True - return bool(DEVIATE_HEAD.search(head)) and t == "是" +SERIAL = deviation.SERIAL def check_response_tables(docs: list[BidDoc], hits: list[Hit]) -> None: - """响应表(表头有「响应情况 / 偏离情况」一栏):这一栏写了「未完全响应」「负偏离」「否」之类,或者空着。 + """响应表(表头有「响应情况 / 偏离」一栏):这一栏写了「未完全响应」「负偏离」「否」之类,或者空着; + 写的不是明确的「无偏离 / 满足」的其他短写法(「不完全一致」「接近要求」)请人核对,按重要报(deviation.classify)。 一条记录从序号行开始,到下一个序号行为止(垂直居中的格子、折行可能落在没有序号的行里)。 表名、表头写「符合性 / 实质性 / 资格」的写了不响应按致命报,其余按重要报;「空着」可能是版面没认准,按重要报。 能对上清单项的,响应匹配也会报,写入前按投标侧句子去重(drop_covered)。""" @@ -1013,18 +1024,18 @@ def check_response_tables(docs: list[BidDoc], hits: list[Hit]) -> None: for _, page in sorted(pages_of(d).items()): rows = rows_of(page) for i, row in enumerate(rows): - at = next((n for n, c in enumerate(row) if RESP_HEAD.search(norm(c.text)) and len(c.text) <= 40), None) - if at is None or len(row) < 3: + if row and SERIAL.fullmatch(norm(row[0].text)): + continue # 以序号开头的是表体(要求栏里也可能写着「……的响应情况」) + heads = [(f"c{n}", norm(c.text)) for n, c in enumerate(row) if deviation.is_dev_head(c.text)] + if not heads or len(row) < 3: continue - head = norm(row[at].text) cols = {f"c{n}": _span(c) for n, c in enumerate(row)} - want = f"c{at}" context = norm("".join(c.text for r in rows[max(0, i - 2) : i + 1] for c in r)) fatal = bool(FATAL_TABLE.search(context)) records: list[list[Sentence]] = [] for r in rows[i + 1 :]: numbered = len(r) >= 2 and SERIAL.fullmatch(norm(r[0].text)) is not None - if not numbered and RESP_HEAD.search(norm("".join(c.text for c in r))): + if not numbered and any(deviation.is_dev_head(c.text) for c in r): break # 下一张表的表头(表体每行都以序号开头,要求栏里也可能写着「……的响应情况」) if numbered: records.append(list(r)) @@ -1033,22 +1044,38 @@ def check_response_tables(docs: list[BidDoc], hits: list[Hit]) -> None: elif records: records[-1] += r for rec in records: - resp = [c for c in rec if _col(c, cols) == want] + columns = [([c for c in rec if _col(c, cols) == want], head) for want, head in heads] + resp = [c for cells, _ in columns for c in cells] others = [c for c in rec[1:] if not any(c is x for x in resp)] if not others: continue req = others[0].text - bad = next((c for c in resp if _negative(c.text, head)), None) - if resp and bad is None: + kind, basis = deviation.judge_row(columns) + joined = norm("".join(c.text for c in basis)) + bad = basis[0] if kind == "negative" else None + unclear = basis[0] if kind == "unclear" else None + if resp and bad is None and unclear is None: continue - if bad is not None: + if bad is None and unclear is not None: + hits.append( + Hit( + "FORM.RESPONSE_NEGATIVE", + "negative_deviation", + "important", + f"响应表里写了「{joined}」", + f"「{req[:40]}」这一行写的是「{joined}」,不是明确的「无偏离 / 满足」,请核对。", + [(d, c) for c in rec], + f"{d.no}:{target(req)}", + ) + ) + elif bad is not None: hits.append( Hit( "FORM.RESPONSE_NEGATIVE", "negative_deviation", "fatal" if fatal else "important", - f"响应表里写了「{norm(bad.text)}」", - f"「{req[:40]}」这一行写的是「{norm(bad.text)}」。" + f"响应表里写了「{joined}」", + f"「{req[:40]}」这一行写的是「{joined}」。" + ( "表名写着符合性 / 实质性审查,这类要求不满足通常会导致投标无效,请核对。" if fatal diff --git a/server/src/bid_doctor/check/service.py b/server/src/bid_doctor/check/service.py index ef1b132..576a6e8 100644 --- a/server/src/bid_doctor/check/service.py +++ b/server/src/bid_doctor/check/service.py @@ -37,7 +37,7 @@ from bid_doctor.project import service as projects from bid_doctor.project.models import Project CHECK_TASK = "check_project" -RULE_SET_VERSION = "2026.11-r2" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r2:加分项 / 单价上限 +RULE_SET_VERSION = "2026.11-r3" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处 WAIVE_WINDOW = timedelta(hours=24) ERROR_MESSAGES = { "bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线", diff --git a/server/src/bid_doctor/checklist/response_mode.py b/server/src/bid_doctor/checklist/response_mode.py index 286bffb..21a1b14 100644 --- a/server/src/bid_doctor/checklist/response_mode.py +++ b/server/src/bid_doctor/checklist/response_mode.py @@ -1,6 +1,6 @@ """招标文件对投标人怎么响应的规定(决策 C12,`evals/check/GUIDELINES.md` §2):按类别(技术 / 商务)识别响应方式。 -- explicit 逐条响应:「逐条 / 逐项作出响应」「逐一逐条响应填写」「逐条标明满足与否」; +- explicit 逐条响应:「逐条 / 逐项作出响应」「逐一逐条响应填写」「逐条标明满足与否」(要说的是要求、参数、偏离); - implicit 默认接受:「否则将认为投标人接受」「未列入上表的视作完全接受」「除所列偏离外均视作理解和响应」 「其余无偏离内容无需赘述」; - 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。 @@ -26,8 +26,13 @@ from bid_doctor.document.models import SourceDocument from bid_doctor.document.pipeline.text_layer import Sentence EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)") +# 「逐条响应」要说的是要求、参数、偏离:声明函填写说明「逐一、准确填写产品名称、厂名……」(招标文件的固定套话)不算 +# (留出集 S03 因此把「未列入偏离的视为接受」判成冲突,规矩的投标文件报了一千多个提示,check-eval §11) +EXPLICIT_SUBJECT = re.compile(r"要求|条款|参数|指标|偏离|规格|需求|配置|技术|商务") +# 「视为」后面是无效、不响应的,说的是不响应的后果,不是默认接受(「否则视为无效响应」,留出集 S09) IMPLICIT = re.compile( - r"(否则|未列入|未列出|未列明|除[^。;]{0,30}外)[^。;]{0,25}(视为|视作|认为)[^。;]{0,15}(接受|响应|理解)" + r"(否则|未列入|未列出|未列明|除[^。;]{0,30}外)[^。;]{0,25}(视为|视作|认为)(?![^。;]{0,3}(无效|不|未|非))" + r"[^。;]{0,15}(接受|响应|理解)" r"|无需赘述" ) TABLE_HINT = re.compile(r"(技术|商务)[^。;]{0,12}(偏离|响应)") @@ -58,7 +63,8 @@ def _scope(i: int, sentences: list[Sentence]) -> set[str]: 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)) + exp = bool(EXPLICIT.search(s.text) and EXPLICIT_SUBJECT.search(s.text)) + imp = bool(IMPLICIT.search(s.text)) if not exp and not imp: continue for c in _scope(i, sentences): diff --git a/server/src/bid_doctor/document/pipeline/text_layer.py b/server/src/bid_doctor/document/pipeline/text_layer.py index 153604b..dca4b8b 100644 --- a/server/src/bid_doctor/document/pipeline/text_layer.py +++ b/server/src/bid_doctor/document/pipeline/text_layer.py @@ -39,6 +39,19 @@ RADICAL_SUPPLEMENT = dict( ) +# 同一个字的异体码位:有的字体把「户」输出成「戶」(U+6236),招标侧、投标侧字形不一致时检索和引文核对会对不上 +# (留出集渲染出的 PDF 就是这样,check-eval §11)。只在比对时折叠(norm),原文照存,和已存的文字层保持一致 +VARIANTS = str.maketrans(dict(zip("戶戸內靑淸眞卽旣敎", "户户内青清真即既教", strict=True))) + + +def fold(s: str) -> str: + """比对用:异体码位、CJK 兼容汉字(U+F900–FAFF)换成常用写法。""" + s = s.translate(VARIANTS) + if any("\uf900" <= c <= "\ufaff" for c in s): + s = "".join(unicodedata.normalize("NFKC", c) if "\uf900" <= c <= "\ufaff" else c for c in s) + return s + + def _char(t: str) -> str: """有的 PDF(如 Chromium 生成的)把汉字映射成康熙部首或部首补充区的码位(「目」成了 U+2F6C)。 只把这两个区换回常用汉字;全角标点等其他字符保持原样,否则引文和原文对不上。""" @@ -369,4 +382,5 @@ def continuations(sentences: list[Sentence]) -> dict[str, Sentence]: def norm(s: str) -> str: - return WS.sub("", s) + """比对用的文本:去空白、折叠异体字(fold)。""" + return fold(WS.sub("", s)) diff --git a/server/tests/test_check_flow.py b/server/tests/test_check_flow.py index 8485ccf..99fb3f4 100644 --- a/server/tests/test_check_flow.py +++ b/server/tests/test_check_flow.py @@ -297,7 +297,7 @@ async def test_rule_findings_are_written_with_citations_and_carried(user: User) # 「让利后总价」和投标总价不同:多半另报了一个价,但不是「备选方案」那种明确的两个报价,按重要 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-r2" + assert run["coverage"]["rule_set_version"] == "2026.11-r3" await user.post(f"/findings/{cap['id']}/resolve", json={"status": "ignored", "note": "大写栏已手工更正"}) again = await run_check(user, project_id, [bid["id"]]) diff --git a/server/tests/test_check_rules.py b/server/tests/test_check_rules.py index 8ff8479..ac25215 100644 --- a/server/tests/test_check_rules.py +++ b/server/tests/test_check_rules.py @@ -257,3 +257,102 @@ def test_validity_in_letter_table_and_chinese_dates() -> None: ("FORM.VALIDITY_SHORT", "fatal"), ("FORM.DATE_AFTER_DEADLINE", "hint"), } + + +def test_generalized_quotes_conditions_and_blanks() -> None: + """留出集暴露的写法(check-eval §11)改成按结构判断;例句既不取自开发集,也不取自留出集。""" + d = doc( + [ + *opening_table("2,000,000.00", "贰佰万元整"), + para(10, "若贵方接受分批付款,我方总价可下调为1,900,000.00元。", page=2), + para(11, "另附选配清单,选配部分合计报价800,000.00元。", page=2), + para(12, "本单位类似项目业绩合同金额2,500,000.00元。", page=2), # 业绩金额:不是另一个报价 + para(13, "我方投标总价为2,000,000.00元,已含全部税费。", page=2), # 等于总价 + para(14, "如遇原材料价格大幅上涨,供货价格由双方另行商定。", page=3), + para(15, "我方保留根据厂家排产情况调整交付周期的权利。", page=3), + para(16, "设备数量以届时实际供货清单为准,合同金额相应结算。", page=3), + para(17, "合同最终以双方签订的合同文本为准。", page=3), # 常见套话:不算 + para(18, "日期选择控件精确到年月日。", page=4), # 说明:不算没填 + para(19, "签署时间:年月日", page=4), + ] + ) + hits, _ = rules.run(rules.Facts(), [d], []) + got = sorted((h.code, h.evidence[0][1].seq) for h in hits) + assert got == [ + ("FORM.BLANK_FIELD", 19), + ("FORM.CONDITION_ADDED", 14), + ("FORM.CONDITION_ADDED", 15), + ("FORM.CONDITION_ADDED", 16), + ("PRICE.MULTIPLE_QUOTES", 10), + ("PRICE.MULTIPLE_QUOTES", 11), + ] + assert {h.severity for h in hits if h.code == "PRICE.MULTIPLE_QUOTES"} == {"important"} + + +def test_deviation_column_values_other_than_clear_ok_need_review() -> 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), + ] + hits, _ = rules.run(rules.Facts(), [doc(rows)], []) + assert [(h.title, h.severity) for h in hits] == [("响应表里写了「基本一致」", "important")] + + +def test_variant_glyphs_compare_equal() -> None: + from bid_doctor.document.pipeline.text_layer import norm + + assert norm("用戶 可以查看") == norm("用户可以查看") + + +def test_response_content_column_and_wrapped_cells_are_not_flagged() -> None: + """有「偏离」栏时只看偏离栏:「投标响应」栏写「支持」「3年」是响应内容,不报;窄栏把「无偏离」折成两段,拼起来判断。""" + rows = [ + cell(1, "序号", 80, 100, w=20), + cell(2, "招标要求", 110, 100, w=120), + cell(3, "投标响应", 240, 100), + cell(4, "偏离", 310, 100, w=30), + cell(5, "1", 82, 124, w=8), + cell(6, "质保期不少于3年", 110, 124, w=120), + cell(7, "3年", 240, 124), + cell(8, "无偏", 310, 124, w=30), + cell(9, "离", 310, 136, w=30), + cell(10, "2", 82, 160, w=8), + cell(11, "支持远程运维", 110, 160, w=120), + cell(12, "支持", 240, 160), + cell(13, "无偏离", 310, 160, w=30), + ] + for c in rows: + x0, y0, x1, _ = c.quads[0] + c.cell = [ + x0, + 100 if y0 == 100 else 124 if y0 in (124, 136) else 160, + x1, + (124 if y0 == 100 else 160 if y0 in (124, 136) else 190), + ] + hits, _ = rules.run(rules.Facts(), [doc(rows)], []) + assert hits == [] + + +def test_long_response_mentioning_noncompliance_is_not_negative() -> None: + """响应栏里一大段正常响应提到「不符合……时预警」,不是不满足;只有开头就写不满足才算。""" + from bid_doctor.check import deviation + + def s(text: str) -> Sentence: + x = cell(1, text, 0, 0) + x.cell = [0, 0, 100, 20] + return x + + long_ok = s("满足,对规划条件不符合村庄规划的用地范围进行高亮标记并预警") + assert deviation.judge([long_ok], "投标响应情况") == "ok" + assert deviation.judge([s("部分响应")], "投标响应情况") == "negative" + assert deviation.judge([s("不满足,现有版本仅支持单机部署")], "投标响应情况") == "negative" + assert deviation.judge([s("无偏"), s("离")], "偏离") == "ok" diff --git a/server/tests/test_check_units.py b/server/tests/test_check_units.py index c0ff7f4..994401b 100644 --- a/server/tests/test_check_units.py +++ b/server/tests/test_check_units.py @@ -91,7 +91,9 @@ def test_response_mode_by_category() -> None: 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, "否则将认为投标人接受招标文件的要求。")]) + 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") @@ -293,3 +295,54 @@ def test_award_time_wording_is_not_award_stage_evidence() -> None: assert applicability.decide(scoring, {}).match later = {**scoring, "requirement_text": "签订合同时提供业绩合同原件备查。"} assert applicability.decide(later, {}).skip == "scoring" + + +def test_response_mode_ignores_declaration_boilerplate_and_negative_consequence() -> None: + """响应方式识别(check-eval §11):声明函「逐一、准确填写产品名称……」不算要求逐条响应; + 「否则视为无效响应」说的是不响应的后果,不算默认接受。""" + from bid_doctor.checklist import response_mode + + implicit = [ + sent(1, "供应商需在《声明函》中逐一、准确填写产品名称、厂名、生产厂址三项信息。"), + sent(2, "对招标文件中的所有商务、技术要求,除本表所列明的所有偏离外,均视作供应商已对之理解和响应。"), + ] + modes = response_mode.detect(implicit) + assert (modes["technical"].mode, modes["technical"].status) == ("implicit", "found") + + explicit = [ + sent(1, "供应商需对磋商文件中的技术要求与商务要求逐项作出响应或偏离说明。"), + sent(2, "供应商须对技术要求中的实质性要求做出明确响应,否则视为无效响应。"), + ] + modes = response_mode.detect(explicit) + assert (modes["technical"].mode, modes["technical"].status) == ("explicit", "found") + + +def test_direct_read_uses_header_column_and_flags_unclear_values() -> None: + """偏离栏按表头认出来:写的不是明确的「无偏离 / 满足」,改判负偏离并标「请核对」。 + 级别最高到重要(check-eval §11)。""" + from bid_doctor.check import deviation + + def c(seq: int, text: str, x0: float, y: float) -> Sentence: + s = Sentence(key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, y + 4, x0 + 90, y + 16]]) + s.cell = [x0, y, x0 + 100, y + 30] + return s + + head = [c(1, "序号", 0, 20), c(2, "招标要求", 100, 20), c(3, "投标响应", 200, 20), c(4, "偏离", 300, 20)] + row1 = [c(5, "1", 0, 50), c(6, "支持双电源", 100, 50), c(7, "满足,支持双电源", 200, 50), c(8, "接近要求", 300, 50)] + row2 = [ + c(9, "2", 0, 80), + c(10, "支持热插拔", 100, 80), + c(11, "满足,支持热插拔", 200, 80), + c(12, "无偏离", 300, 80), + ] + d = match.BidDoc(uuid4(), 1, "技术分册", [*head, *row1, *row2]) + a, b = uuid4(), uuid4() + results = { + a: match.Result("responded", "ai", "verified", evidence=[(d, row1[2])]), + b: match.Result("responded", "ai", "verified", evidence=[(d, row2[2])]), + } + assert deviation.direct_read(results, [d]) == 1 + assert results[a].verdict == "negative_deviation" and results[a].detail["deviation_unclear"] is True + 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("偏离栏需核对")