fix(check): 修留出集暴露的 5 处(规则集 2026.11-r3)

- 响应方式:「逐条响应」要说的是要求 / 参数 / 偏离,声明函填写说明不算;「否则视为无效响应」不算默认接受
- 偏离栏按「是不是明确满足」判断:偏离栏、响应情况栏一起看,其他短写法请人核对(最高重要);表体序号认「技-86」等
- 另报价、附加条件按结构判断(另一个不同的总价金额、保留 / 另行 / 调整报价等)
- 空白栏:「精确到年月日」不算没填
- 比对时折叠异体字(戶→户等),原文照存
- 记录决策 E2:先修再跑国动真实投标文件

开发集召回 75%、规矩投标文件问题数不变;留出集参考数字见 check-eval §12(已看过,仅参考)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 20:34:12 +08:00
parent fee1c0f842
commit 902a130c82
13 changed files with 436 additions and 70 deletions

View File

@@ -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"(?<!基本)(?<!大致)(?<!大体)(?<!接近)(?<!部分)(无偏|正偏|满足|完全响应|符合|一致)")
def judge(cells: list[Sentence], head: str) -> 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

View File

@@ -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 = (

View File

@@ -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):

View File

@@ -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"(?<![\d〇零一二三四五六七八九十])(?<!出生)(?<!成立)(?<!注册)年\s*月\s*日"), "日期没有填"),
# 「精确到年月日」「格式为年月日」「显示年月日时分」是说明,不是没填(留出集,check-eval §11)
(
re.compile(
r"(?<![\d〇零一二三四五六七八九十])(?<!出生)(?<!成立)(?<!注册)(?<!精确到)(?<!格式为)(?<!显示)(?<!格式)"
r"年\s*月\s*日(?!\s*[时\d])"
),
"日期没有填",
),
(re.compile(r"[((]\s*[))](?=\s*[((][^))]{2,12}[))])"), "括号里没有填"),
(re.compile(r"[::]\s*[((]\s*[))]"), "括号里没有填"),
(
@@ -945,15 +964,22 @@ CONDITIONS = (
),
re.compile(r"按(实际|实际发生|实际需求|实际使用)[^。;,]{0,10}(结算|计算|计价|收费|计费)"),
re.compile(r"(费用|费)[^。;,]{0,6}由(采购人|甲方|贵方|用户|业主)[^。;,]{0,4}(承担|负责|支付|另付)"),
re.compile(r"另行(计算|计取|收取|收费|结算|报价|协商|商定|商议|约定|确定)|另议|另计"),
re.compile(r"另行(计算|计取|收取|收费|结算|报价|协商|商定|商议|约定|确定|签订|商洽)|另议|另计"),
re.compile(r"以(实际|最终|现场|实际发生)[^。;,]{0,12}为准"),
re.compile(r"以[^。;,]{0,16}(另行|实际|最终|现场|届时|我方)[^。;,]{0,16}为准"),
re.compile(r"保留[^。;,]{0,12}(权利|调整|变更)"),
re.compile(r"(调整|变更|上调|上浮)[^。;,]{0,4}(报价|价格|单价|总价)"),
re.compile(r"(价格|市场价|原材料|元器件)[^。;,]{0,10}(波动|上涨|变化)"),
re.compile(r"(价格|报价|单价|总价)[^。;]{0,10}(随|根据|按照?)[^。;]{0,12}(调整|浮动|变动)"),
re.compile(r"仅供参考"),
re.compile(r"(须|需|需要|要求)[^。;]{0,6}(预付|预付款|首付款)"),
)
COMMERCIAL = re.compile(r"报价|价格|价款|总价|单价|费用|税|付款|支付|结算|数量|工程量|到货|交货|工期|质保|合同")
COMMERCIAL = re.compile(
r"报价|价格|价款|总价|单价|费用|税|付款|支付|结算|数量|工程量|到货|交货|交付|供货|工期|服务期|质保|售后|协议|合同"
)
NOT_CONDITION = re.compile(
r"(不|无|未|免|不再|无需|不得|决不|不会)[^。;,]{0,4}(另行|另计|另议)|无效|否则|不予|不接受|免费|不影响|不增加"
r"|(不|不得|不会|决不)以任何理由"
)
@@ -984,28 +1010,13 @@ def check_conditions(docs: list[BidDoc], tender: Tender, hits: list[Hit]) -> 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

View File

@@ -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": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",

View File

@@ -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):

View File

@@ -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))