Files
bid-doctor/evals/check/mutate.py
YANG JIANKUAN b2b8584321 feat(evals): 留出集生成——风格 C、留出集改写导出;评测可模拟人工确认响应方式
- build.py / mutate.py:补风格 C(叙述型),由封存一侧的会话按封存说明实现;开发集风格 A / B 输出逐字节不变
- build.py:复述要求时去行首编号不再吃掉「2路服务器」这类内容数字
- export_rewrite.py:没有风格 A 稿时从未改写的风格 B 稿导出
- run_check.py:--modes 模拟清单页上人工确认的响应方式(C12 的人工确认点)
- check-eval.md:第 3 步冻结点(a60d16e)

查标代码(server/src、prompts)自冻结后未改。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:58:52 +08:00

737 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""往规矩的投标文件稿里注入问题,写出马虎的投标文件稿和响应清单(evals/check/GUIDELINES.md §3、§6.2)。
cd server && ../.venv/bin/python ../evals/check/mutate.py <规矩的投标文件> <输出的投标文件> --split dev [--seed 1] [--density high|low]
cd server && ../.venv/bin/python ../evals/check/mutate.py <规矩的投标文件> --clean --split dev # 只给规矩的投标文件写响应清单
- 词库:开发集用 evals/check/vocab-dev.json;留出集用封存的 evals/out/check/_sealed/vocab-holdout.json;
- 注入是确定性的(同一个 seed 结果相同),每个问题记 defect、variant、granularity、worded;
- 问题只挂在标注单元上;挂上的单元先记 reviewed: false,复核(决策 E1)后改成 true,评分只算复核过的;
- 响应方式按单元在投标文件里的响应位置推出来:逐条响应表 → explicit,只列偏离的表 → implicit,附件 → evidence,格式文件 → presence;
风格 C 的叙述段落(form: narr)→ explicit(招标文件要求逐项响应,只是写成了叙述)。
- 风格 C:技术响应在叙述段落里,按段注入(漏写一段 / 连续几段、数值改错、写明不支持、只说满足不给值、段落张冠李戴),
负偏离未说明加在技术偏离表的总述行后面;其余(附件、规则问题)和风格 A / B 相同。开发集(A / B)的注入路径不变。
"""
from __future__ import annotations
import argparse
import copy
import json
import pathlib
import random
import re
import sys
from decimal import Decimal
from typing import Any
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
sys.path.insert(0, str(EVALS / "check"))
import gold # noqa: E402
from build import narr_head, rmb_upper # noqa: E402
OUT = EVALS / "out" / "check"
VOCAB = {"dev": EVALS / "check" / "vocab-dev.json", "holdout": OUT / "_sealed" / "vocab-holdout.json"}
# 「暂不支持」只对功能类要求说得通;证书、清单、服务义务、文档交付、改造范围这类不是功能(复核两批共拒掉 12 条)
FUNCTIONAL = re.compile(r"支持|功能|具备|实现|可对|能够|可以|兼容|提供.{0,6}接口")
NON_FUNCTIONAL = re.compile(r"证书|认证|清单|服务|培训|文档|资料|方案|范围|记录|报告|期限|质保|交付|人员|工期|成果")
# 条件性要求:只对特定投标人适用(「如依法免税则须提供……」),规矩的投标人不适用,不能当作必须提供的材料
CONDITIONAL = re.compile(r"(如|若|对于)[^,。;]{0,12}(免税|不需|无需|依法|属于|是)")
DOC_REQ = re.compile(r"提供.{0,12}(方案|证明|报告|文件|承诺|截图|复印件|证书)")
COUNT_UNIT = re.compile(r"^(个|款|套|台|名|人|次|项|种|份|块|盏|家|条|路|口|颗|张)$")
UNIT_DOWN = {"TB": ("GB", 1024), "GB": ("MB", 1024), "Gbps": ("Mbps", 1000), "s": ("ms", 1000), "秒": ("毫秒", 1000)}
class Mutator:
def __init__(self, spec: dict[str, Any], vocab: dict[str, Any], rng: random.Random) -> None:
self.spec, self.vocab, self.rng = spec, vocab, rng
self.vols = spec["volumes"]
self.unit_defects: dict[str, dict[str, Any]] = {}
self.rule_defects: list[dict[str, Any]] = []
self.umbrella: set[str] = set()
self.no_propagate = False
self.rejected: set[str] = set() # 以前复核判为不成立的单元(同一招标文件的任何投标文件里),不再挂问题
# 在表格以外(技术方案散文、附件、格式文件)也响应了的单元:删表格行、改响应值都不构成问题,不挂这几类
self.prose = [
{t[i : i + 2] for t in [re.sub(r"\W", "", b["text"])] for i in range(len(t) - 1)}
for b in self.blocks
if b.get("type") == "para" and b.get("form") == "prose"
]
self.elsewhere = {
u
for b in self.blocks
if b.get("type") != "table" and b.get("form") != "narr" # 风格 C 的叙述段落就是逐项响应本身
for u in b.get("responds") or []
}
@property
def blocks(self) -> list[dict[str, Any]]:
"""所有分册的块(只读视图);增删用 remove / insert_after。"""
return [b for v in self.vols for b in v["blocks"]]
def remove(self, b: dict[str, Any]) -> None:
for v in self.vols:
if b in v["blocks"]:
v["blocks"].remove(b)
def insert_after(self, anchor: dict[str, Any], new: dict[str, Any]) -> None:
for v in self.vols:
if anchor in v["blocks"]:
v["blocks"].insert(v["blocks"].index(anchor) + 1, new)
return
# ---------- 定位 ----------
def tables(self, *, explicit: bool | None = None, fixed: bool = False) -> list[dict[str, Any]]:
out = []
for b in self.blocks:
if b.get("type") != "table" or not b.get("form"):
continue
if fixed:
if b.get("fixed"):
out.append(b)
continue
if b.get("fixed") or "only_deviations" not in b:
continue
mode = table_mode(b)
if explicit is None or (explicit and mode == "explicit") or (explicit is False and mode == "implicit"):
out.append(b)
return out
def free(self, units: list[str]) -> bool:
return bool(units) and not any(u in self.unit_defects or u in self.rejected for u in units)
def mark(
self, units: list[str], defect: str, variant: str, granularity: str = "single", worded: bool = True
) -> None:
for u in units:
self.unit_defects[u] = {"defect": defect, "variant": variant, "granularity": granularity, "worded": worded}
def phrase(self, key: str, field: str) -> str:
return str(self.rng.choice(self.vocab[key][field]))
# ---------- 要求项问题 ----------
def d01_block(self) -> None:
"""连续漏掉 5~10 行(逐条响应表)。"""
for t in self.rng.sample(self.tables(explicit=True), k=len(self.tables(explicit=True))):
rows = t["rows"]
if len(rows) < 12:
continue
n = self.rng.randint(5, 10)
start = self.rng.randint(0, len(rows) - n)
chunk = rows[start : start + n]
if not all(self.free(r.get("responds") or []) for r in chunk if r.get("responds")):
continue
if any(set(r.get("responds") or []) & self.elsewhere for r in chunk):
continue
for r in chunk:
self.mark(r.get("responds") or [], "D01", "删连续一段", "block", worded=False)
self.drop_everywhere([u for r in chunk for u in r.get("responds") or []], [(t, r) for r in chunk])
return
def row_ops(self, n: int) -> None:
"""逐条响应表里的单点问题:漏行、数值不满足、写明不支持、负偏离未说明、只写满足、对象错位。"""
cands = [(t, r) for t in self.tables(explicit=True) for r in t["rows"] if self.free(r.get("responds") or [])]
self.rng.shuffle(cands)
ops = ["D01", "D03", "D03", "D05", "D06", "D08", "D09"]
done = 0
for t, r in cands:
if done >= n:
break
if r not in t["rows"] or not self.free(r.get("responds") or []):
continue
# 从轮到的类型开始,这一行做不了就换下一种(软件类项目几乎没有数值参数,D03 常常做不了)
for k in range(len(ops)):
if self._apply(ops[(done + k) % len(ops)], t, r, cands):
done += 1
break
def in_prose(self, text: str) -> bool:
"""技术方案散文里基本复述了这条要求(字 2-gram 有六成以上出现在同一段里):表格里删掉或改错,散文里还在响应。"""
t = re.sub(r"\W", "", text)
grams = {t[i : i + 2] for i in range(len(t) - 1)}
return len(grams) >= 6 and any(len(grams & g) / len(grams) >= 0.6 for g in self.prose)
def _req_text(self, t: dict[str, Any], r: dict[str, Any]) -> str:
at = self.col(t, "requirement")
return re.sub(r"\W", "", r["cells"][at]) if at is not None else ""
def siblings(self, t: dict[str, Any], r: dict[str, Any]) -> list[tuple[dict[str, Any], dict[str, Any]]]:
"""同一条要求在投标文件里的所有表格行:挂同一个标注单元,或者要求原文相同(读标清单常把同一要求抽成几条,
S04 的参数同时列在两张表里)。注入的问题要在这些行上一起做,否则别处还留着正确的响应,就不是问题了。"""
units, text = set(r.get("responds") or []), self._req_text(t, r)
out = []
for t2 in self.tables():
for r2 in t2["rows"]:
if r2 is r:
continue
if units & set(r2.get("responds") or []) or (len(text) >= 6 and self._req_text(t2, r2) == text):
out.append((t2, r2))
return out
def propagate(self, t: dict[str, Any], r: dict[str, Any]) -> None:
"""把这一行改过的响应、偏离、说明照样写到同一要求的其他行上。"""
if self.no_propagate: # 风格 C 的商务表:同一单元的几行是几款不同的条款,各写各的
return
for t2, r2 in self.siblings(t, r):
for field in ("answer", "deviation", "note"):
a, b = self.col(t, field), self.col(t2, field)
if a is not None and b is not None:
r2["cells"][b] = r["cells"][a]
def drop_everywhere(self, units: list[str], rows: list[tuple[dict[str, Any], dict[str, Any]]] = ()) -> None:
"""漏响应要在所有表里都漏:S04 的技术参数同时列在全量偏离表和 ★ 表里,只删一张表等于没漏;
要求原文相同的重复行也一起删。"""
drop = {id(r2) for t, r in rows for _, r2 in self.siblings(t, r)} | {id(r) for _, r in rows}
for t in self.tables():
t["rows"] = [r for r in t["rows"] if id(r) not in drop and not set(r.get("responds") or []) & set(units)]
@staticmethod
def col(t: dict[str, Any], field: str) -> int | None:
cols = t.get("columns") or ["no", "requirement", "answer", "deviation", "note"]
return cols.index(field) if field in cols else None
def _apply(self, op: str, t: dict[str, Any], r: dict[str, Any], cands: list[Any]) -> bool:
ans, dev, note, req = (self.col(t, f) for f in ("answer", "deviation", "note", "requirement"))
if ans is None:
return False
tp = r.get("tp") or {}
units = r.get("responds") or []
req_text = r["cells"][req] if req is not None else ""
if DOC_REQ.search(req_text): # 「提供……方案 / 证明 / 报告」是文件要在(presence),不注入参数类问题
return False
numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value")
if op == "D05" and (numeric or not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text)):
return False # 「暂不支持」只套在功能类要求上(数值参数、证书、清单、服务义务不会这么写)
if op in ("D01", "D03", "D08", "D09") and (set(units) & self.elsewhere or self.in_prose(req_text)):
return False
if op == "D01":
self.drop_everywhere(units, [(t, r)])
self.mark(units, "D01", "删一行", worded=False)
elif op == "D03":
if not self._unmet(r, tp, ans, dev):
return False
elif op == "D05":
param = tp.get("param") or (r["cells"][req][:12] if req is not None else "该功能")
r["cells"][ans] = self.phrase("D05", "phrases").format(param=param)
self.mark(units, "D05", r["cells"][ans])
elif op == "D06":
if dev is None:
return False
r["cells"][dev] = self.phrase("D06", "deviations")
if note is not None:
r["cells"][note] = ""
self.mark(units, "D06", r["cells"][dev])
elif op == "D08":
if not tp.get("value"):
return False
r["cells"][ans] = self.phrase("D08", "phrases")
self.mark(units, "D08", r["cells"][ans], worded=False)
elif op == "D09":
mine = str(tp.get("param") or "")
others = [
x
for _, x in cands
if x is not r
and (x.get("tp") or {}).get("param")
and x["tp"]["param"] != mine # 必须是另一个参数:同名参数(另一台设备的同一指标)的响应可能恰好满足
and (not mine or mine not in x["cells"][x.get("anchor", ans)])
and x["cells"][x.get("anchor", ans)] != r["cells"][ans]
]
if not others:
return False
o = self.rng.choice(others)
r["cells"][ans] = o["cells"][o.get("anchor", ans)]
self.mark(units, "D09", "另一条参数的响应", worded=False)
if op != "D01":
self.propagate(t, r)
return True
def _unmet(self, r: dict[str, Any], tp: dict[str, Any], ans: int, dev: int | None) -> bool:
"""把响应值改成不满足;一部分换成更小的单位(换算后不满足)。不带标志词。"""
op, unit = tp.get("op"), tp.get("unit") or ""
try:
v = Decimal(str(tp.get("value")))
except Exception: # noqa: BLE001
return False
if op not in (">=", "<=") or v == 0:
return False
bad = (v / 2) if op == ">=" else (v * Decimal("1.6"))
variant = "低于下限" if op == ">=" else "高于上限"
if unit in UNIT_DOWN and self.rng.random() < 0.4:
small, k = UNIT_DOWN[unit]
text, variant = f"{(bad * k).normalize():f}{small}", "换算后不满足"
else:
text = f"{bad.normalize():f}{unit}"
r["cells"][ans] = f"{tp.get('param') or ''}:{text}"
if dev is not None and self.rng.random() < 0.5:
r["cells"][dev] = "无偏离" # 一半在偏离栏仍写「无偏离」(另一半保持原样,也是无偏离或正偏离)
self.mark(r.get("responds") or [], "D03", variant, worded=False)
return True # 同一要求的其他行由调用方(_apply)同步
def implicit_deviation(self, n: int) -> None:
"""只列偏离的表:挑默认接受的要求写成偏离(D06 负偏离未说明 / D05 写明不支持)。"""
for t in self.tables(explicit=False):
req_col = self.col(t, "requirement")
pool = [
r
for r in t.get("pool") or []
if self.free(r.get("responds") or [])
and not (req_col is not None and DOC_REQ.search(r["cells"][req_col]))
]
self.rng.shuffle(pool)
ans, dev, note, req = (self.col(t, f) for f in ("answer", "deviation", "note", "requirement"))
if ans is None or dev is None:
continue
for k, r in enumerate(pool[:n]):
row = copy.deepcopy(r)
if k % 2 == 0:
row["cells"][dev] = self.phrase("D06", "deviations")
if note is not None:
row["cells"][note] = ""
self.mark(row["responds"], "D06", row["cells"][dev])
elif (tp := row.get("tp") or {}).get("op") in (">=", "<=") and (
(v := decimal_or_none(tp.get("value"))) is not None
):
# 数值参数不会写「暂不支持」:写一个不满足的值并标负偏离(值不是纯数字的按功能类处理)
bad = v / 2 if tp["op"] == ">=" else v * Decimal("1.6")
row["cells"][ans] = f"{tp.get('param') or ''}:{bad.normalize():f}{tp.get('unit') or ''}"
row["cells"][dev] = "负偏离"
self.mark(row["responds"], "D06", "数值不满足并写负偏离")
else:
# 风格 C 的偏离表只写要求简称,判断功能类用原文(req_text)
req_text = row.get("req_text") or (row["cells"][req] if req is not None else "")
if not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text):
row["cells"][dev] = self.phrase("D06", "deviations") # 非功能要求:写负偏离而不是「不支持」
if note is not None:
row["cells"][note] = ""
self.mark(row["responds"], "D06", row["cells"][dev])
if t["rows"] and "—" in t["rows"][0]["cells"]:
t["rows"].clear()
if self.col(t, "no") is not None:
row["cells"][self.col(t, "no")] = str(len(t["rows"]) + 1)
t["rows"].append(row)
continue
param = tp.get("param") or (req_text[:12] or "该功能")
row["cells"][ans] = self.phrase("D05", "phrases").format(param=param)
row["cells"][dev] = "负偏离"
self.mark(row["responds"], "D05", row["cells"][ans])
if t["rows"] and "—" in t["rows"][0]["cells"]:
t["rows"].clear()
if self.col(t, "no") is not None:
row["cells"][self.col(t, "no")] = str(len(t["rows"]) + 1)
t["rows"].append(row)
def attachments(self, n: int, cats: dict[str, str], texts: dict[str, str]) -> None:
"""删掉附件:资格条件 → D11,评分项 → D13,其他 → D02。
伞状单元(「第二十二条规定的条件」这类总述,挂在 3 份以上附件上)不挂问题:查标报的是具体条目,和总述对不上;
条件性要求(「如依法免税则须提供」)规矩的投标人不适用,它的附件不删。"""
uses: dict[str, int] = {}
for b in self.blocks:
if b.get("attach"):
for u in b.get("responds") or []:
uses[u] = uses.get(u, 0) + 1
self.umbrella = {u for u, k in uses.items() if k >= 3}
cands = [
b
for b in self.blocks
if b.get("attach")
and self.free([u for u in b.get("responds") or [] if u not in self.umbrella])
and not any(CONDITIONAL.search(texts.get(u, "")) for u in b.get("responds") or [])
]
self.rng.shuffle(cands)
for b in cands[:n]:
units = [u for u in b.get("responds") or [] if u not in self.umbrella]
kinds = {cats.get(u) for u in units}
defect = "D11" if "qualification" in kinds else "D13" if "scoring" in kinds else "D02"
self.mark(units, defect, "附件缺失", worded=False)
self.remove(b)
def compliance(self) -> None:
for t in self.tables(fixed=True):
if not any("响应" in str(h) for h in t.get("header") or []):
continue # 只有响应表(表头有「响应情况」栏)才注入;人员表、业绩表不是
rows = [r for r in t["rows"] if self.free(r.get("responds") or [])]
if not rows:
continue
r = self.rng.choice(rows)
at = r.get("anchor", len(r["cells"]) - 2)
r["cells"][at] = self.phrase("D10", "cells")
# 符合性条件响应表的每一行没法对到单个标注单元(整张表共用一组),按表级问题记一个
self.rule("D10", r["cells"][at] or "空着", r["cells"][1] if len(r["cells"]) > 1 else "")
return
# ---------- 风格 C:叙述段落 ----------
def narr_paras(self) -> list[dict[str, Any]]:
return [b for b in self.blocks if b.get("type") == "para" and b.get("form") == "narr"]
def shared(self, b: dict[str, Any], skip: tuple[dict[str, Any], ...] = ()) -> bool:
"""b 的单元在 b 以外(skip 里的块除外)还有响应:叙述段落、表格行或其他块。"""
mine = set(b.get("responds") or [])
for x in self.blocks:
if x is b or any(x is y for y in skip):
continue
rows = x["rows"] if x.get("type") == "table" else [x]
if any(mine & set(r.get("responds") or []) for r in rows):
return True
return False
def narr_block(self) -> None:
"""同一节里连续漏掉 5~8 段(至少 3 段挂了标注单元)。"""
secs: dict[int, list[dict[str, Any]]] = {}
for b in self.narr_paras():
secs.setdefault(b["sec"], []).append(b)
for paras in self.rng.sample(list(secs.values()), k=len(secs)):
if len(paras) < 10:
continue
n = self.rng.randint(5, 8)
starts = list(range(len(paras) - n + 1))
self.rng.shuffle(starts)
for start in starts:
chunk = paras[start : start + n]
units = [u for b in chunk for u in b.get("responds") or []]
if sum(1 for b in chunk if b.get("responds")) < 3 or not self.free(units):
continue
if set(units) & self.elsewhere or any(self.in_prose(b["req_text"]) for b in chunk):
continue
if any(self.shared(b, tuple(chunk)) for b in chunk):
continue
for b in chunk:
self.mark(b.get("responds") or [], "D01", "删连续几段", "block", worded=False)
self.remove(b)
return
def narr_ops(self, n: int) -> int:
"""叙述段落里的单点问题,类型按 row_ops 的比例轮换;轮到的类型去找做得了的段落
(服务类项目的数值段落少,逐段试的话几乎全落到负偏离上)。一轮下来一个都做不了就停。"""
cands = [b for b in self.narr_paras() if self.free(b.get("responds") or [])]
self.rng.shuffle(cands)
ops = ["D01", "D03", "D03", "D05", "D06", "D08", "D09"]
done, k, idle = 0, 0, 0
while done < n and idle < len(ops):
op = ops[k % len(ops)]
k += 1
for b in cands:
if b in self.blocks and self.free(b.get("responds") or []) and self._apply_narr(op, b):
done += 1
idle = 0
break
else:
idle += 1
return done
def _apply_narr(self, op: str, b: dict[str, Any]) -> bool:
tp, units, req_text = b.get("tp") or {}, b.get("responds") or [], b.get("req_text") or ""
if DOC_REQ.search(req_text):
return False
numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value")
if op == "D05" and (numeric or not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text)):
return False
if op in ("D01", "D03", "D05", "D08", "D09") and self.shared(b):
return False # 同一单元在别的段落 / 表格行里也有响应:删改这一段不构成问题
if op in ("D01", "D03", "D08", "D09") and (set(units) & self.elsewhere or self.in_prose(req_text)):
return False
if op == "D01":
self.remove(b)
self.mark(units, "D01", "删一段", worded=False)
elif op == "D03":
if not b.get("val") or tp.get("op") not in (">=", "<="):
return False
try:
v = Decimal(str(tp["value"]))
except Exception: # noqa: BLE001
return False
if v == 0:
return False
unit = tp.get("unit") or ""
if v == v.to_integral_value() and COUNT_UNIT.search(unit): # 个数不会写成 0.5 款:差 1 个
if tp["op"] == ">=" and v < 2:
return False
bad = v - 1 if tp["op"] == ">=" else v + 1
else:
bad = (v / 2) if tp["op"] == ">=" else (v * Decimal("1.6"))
variant = "低于下限" if tp["op"] == ">=" else "高于上限"
if unit in UNIT_DOWN and self.rng.random() < 0.4:
small, k = UNIT_DOWN[unit]
text, variant = f"{(bad * k).normalize():f}{small}", "换算后不满足"
else:
text = f"{bad.normalize():f}{unit}"
b["body"] = b["tmpl"].replace("{v}", text)
self.mark(units, "D03", variant, worded=False)
elif op == "D05":
param = tp.get("param") or b["label"]
b["body"] = self.phrase("D05", "phrases").format(param=param)
self.mark(units, "D05", b["body"])
elif op == "D06":
t = next((x for x in self.blocks if x.get("summary") and x.get("route") == "tech_row"), None)
if t is None:
return False
ans, dev = self.col(t, "answer"), self.col(t, "deviation")
if ans is None or dev is None:
return False
value = {
"no": str(len(t["rows"]) + 1),
"requirement": b["label"],
"answer": b["body"],
"deviation": self.phrase("D06", "deviations"),
}
cols = t["columns"]
t["rows"].append({"cells": [value.get(c, "") for c in cols], "anchor": ans, "responds": units})
self.mark(units, "D06", value["deviation"])
elif op == "D08":
if not tp.get("value"):
return False
b["body"] = self.phrase("D08", "phrases")
self.mark(units, "D08", b["body"], worded=False)
elif op == "D09":
mine = str(tp.get("param") or "")
others = [
x
for x in self.narr_paras()
if x is not b
and (x.get("tp") or {}).get("param")
and x["tp"]["param"] != mine
and x["body"] != b["body"]
and (not mine or mine not in x["body"])
and x["body"] == x["tmpl"].replace("{v}", x.get("val") or "") # 只抄没改过的段落
and not set(x.get("responds") or []) & set(self.unit_defects)
]
if not mine or not others:
return False
b["body"] = self.rng.choice(others)["body"] # 复制错了别处的一段:原来的要求没有响应
self.mark(units, "D09", "另一条要求的响应", worded=False)
return True
def narr_finish(self) -> None:
"""叙述段落按改过的内容重写文字,并按节重新编号(漏掉的段落不留空号)。"""
k: dict[int, int] = {}
for b in self.narr_paras():
k[b["sec"]] = k.get(b["sec"], 0) + 1
b["head"] = narr_head(k[b["sec"]], b["label"])
b["text"] = b["head"] + b["body"]
# ---------- 规则问题 ----------
def rule(self, kind: str, variant: str, snippet: str) -> None:
self.rule_defects.append(
{"id": f"{kind}-{len(self.rule_defects) + 1}", "type": kind, "variant": variant, "t": snippet}
)
def price_table(self) -> dict[str, Any] | None:
return next((b for b in self.blocks if b.get("price_summary")), None)
def p04(self) -> None:
t = self.price_table()
if not t or len(t["rows"][0]["cells"]) < 3: # 开标一览表没有大写金额栏(S04):做不了
return
variant = self.phrase("P04", "variants")
total = Decimal(self.spec["price"]["total"])
wrong = {"drop_digit": total / 10, "extra_shi": total + 10, "decimal_shift": total / 100}.get(
variant, total + 1
)
t["rows"][0]["cells"][2] = rmb_upper(wrong.quantize(Decimal("0.01")))
self.rule("P04", variant, t["rows"][0]["cells"][2])
def p05(self) -> None:
t = next((b for b in self.blocks if b.get("price_items")), None)
if not t or not t["rows"]:
return
r = self.rng.choice(t["rows"])
r["cells"][-1] = f"{Decimal(r['cells'][-1].replace(',', '')) + 1000:,.2f}"
self.rule("P05", "分项总价多 1000 元", r["cells"][-1])
def p06(self) -> None:
fp = self.rng.choice(self.vocab["P06"]["foreign_projects"])
self.vols[-1]["header"] = fp["name"] # 技术分册照搬旧标书最常见
self.rule("P06", "页眉留旧项目名", fp["name"])
def text_paras(self, form_hint: str) -> list[dict[str, Any]]:
return [b for b in self.blocks if b.get("type") == "para" and form_hint in str(b.get("form") or "")]
def p07(self) -> None:
bidder = self.spec["bidder"]["bidder"]
paras = [b for b in self.blocks if b.get("type") == "para" and b.get("form") and bidder in b["text"]]
if paras:
b = self.rng.choice(paras)
blank = self.phrase("P07", "blanks")
b["text"] = b["text"].replace(bidder, blank, 1)
self.rule("P07", "投标人名称空", blank_context(b["text"], blank))
def p08(self) -> None:
signs = [b for b in self.blocks if b.get("type") == "sign"]
if signs:
date = self.phrase("P08", "dates")
b = self.rng.choice(signs)
b["lines"] = [re.sub(r"日期:.*", f"日期:{date}", x) for x in b["lines"]]
self.rule("P08", date, date)
def p09(self) -> None:
days = str(self.spec.get("validity_days") or 90)
for b in self.blocks:
if b.get("type") == "para" and f"{days}天" in b["text"].replace(" ", ""):
new = str(self.phrase("P09", "days"))
b["text"] = b["text"].replace(f"{days}天", f"{new}天").replace(f"{days} 天", f"{new}天")
self.rule("P09", f"{days} → {new} 天", f"{new}天")
return
def p10(self) -> None:
letter = next((i for i, b in enumerate(self.blocks) if b.get("type") == "para" and b.get("form")), None)
if letter is not None:
text = self.phrase("P10", "phrases")
self.insert_after(self.blocks[letter], {"type": "para", "text": text, "form": self.blocks[letter]["form"]})
self.rule("P10", text, text)
def p12(self) -> None:
t = self.price_table()
if t:
alt = f"{Decimal(self.spec['price']['total']) * Decimal('0.97'):,.2f}"
text = self.phrase("P12", "phrases").format(alt=alt)
self.insert_after(t, {"type": "para", "text": text})
self.rule("P12", text, text)
def decimal_or_none(value: Any) -> Decimal | None:
try:
return Decimal(str(value)) if value else None
except Exception: # noqa: BLE001
return None
def blank_context(text: str, blank: str) -> str:
"""空白处前后的原文(不跨句号),评分时拿它对照问题的投标侧出处。"""
i = text.index(blank)
start = max(i - 10, max(text.rfind(x, 0, i) for x in "。;") + 1)
return text[start : i + len(blank) + 10]
def table_mode(b: dict[str, Any]) -> str:
"""表格的响应方式:档案里表单写了 mode 的以它为准(如 S02 评分要的技术规格表是 presence,不是逐条响应的机制)。"""
return str(b.get("mode") or ("implicit" if b.get("only_deviations") else "explicit"))
def unit_modes(spec: dict[str, Any]) -> dict[str, str]:
"""单元的响应方式:逐条响应表里有 → explicit;只在只列偏离的表里 → implicit;附件 → evidence;格式文件 → presence。"""
modes: dict[str, str] = {}
rank = {"explicit": 0, "implicit": 1, "evidence": 2, "presence": 3}
blocks = [b for v in spec["volumes"] for b in v["blocks"]]
for b in blocks:
if b.get("type") == "table" and "only_deviations" in b:
mode = table_mode(b)
for r in [*b["rows"], *(b.get("pool") or [])]:
for u in r.get("responds") or []:
if u not in modes or rank.get(mode, 9) < rank.get(modes[u], 9):
modes[u] = mode
elif b.get("form") == "narr": # 风格 C 的叙述段落:逐项响应
for u in b.get("responds") or []:
if u not in modes or rank.get(modes[u], 9) > 0:
modes[u] = "explicit"
elif b.get("attach"):
for u in b.get("responds") or []:
modes.setdefault(u, "evidence")
elif b.get("form"):
for r in b.get("rows") or [b]:
for u in r.get("responds") or []:
modes.setdefault(u, "presence")
return modes
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("clean")
ap.add_argument("out", nargs="?")
ap.add_argument("--clean", dest="manifest_only", action="store_true", help="只给规矩的投标文件写响应清单")
ap.add_argument("--split", required=True, choices=["dev", "holdout"])
ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--density", default="high", choices=["high", "low"])
a = ap.parse_args()
spec = json.loads((OUT / a.clean / "spec.json").read_text())
tender = spec["tender"]
units, _ = gold.units(tender)
cats = {u.id: u.cat for u in units}
texts = {
u.id: "".join(str(x.get("t", "")) for x in u.raw.get("anchors") or []) + str(u.raw.get("note") or "")
for u in units
}
optional = {u.id for u in units if u.optional}
modes = unit_modes(spec) # 注入前算:被删掉的行也要知道它本来该怎么响应
m = Mutator(spec, json.loads(VOCAB[a.split].read_text()), random.Random(a.seed)) # noqa: S311 评测数据,不用于安全
for f in OUT.glob(f"{tender}-*/manifest.json"):
for uid, e in json.loads(f.read_text())["units"].items():
if (e.get("review") or {}).get("ok") is False:
m.rejected.add(uid)
if not a.manifest_only and spec["style"] == "C":
if a.density == "high":
m.narr_block()
rest = 18 - m.narr_ops(18) # 默认接受的招标文件(技术要求不写进叙述):余下的配额给只列偏离的表和附件
m.no_propagate = True
m.row_ops(2) # 商务响应表仍是逐条表格
m.implicit_deviation(4 + rest * 2 // 3)
m.attachments(4 + rest // 3, cats, texts)
m.compliance()
for op in (m.p04, m.p05, m.p06, m.p07, m.p08, m.p09, m.p10, m.p12):
op()
else:
m.narr_ops(4)
m.attachments(1, cats, texts)
m.p04()
m.p07()
m.narr_finish()
elif not a.manifest_only:
if a.density == "high":
m.d01_block()
m.row_ops(18)
m.implicit_deviation(4)
m.attachments(4, cats, texts)
m.compliance()
for op in (m.p04, m.p05, m.p06, m.p07, m.p08, m.p09, m.p10, m.p12):
op()
else:
m.row_ops(4)
m.attachments(1, cats, texts)
m.p04()
m.p07()
bid = a.clean if a.manifest_only else a.out
assert bid
manifest_units: dict[str, Any] = {}
na = set(spec.get("na_units") or [])
for u in units:
mode = modes.get(u.id) or ("na" if u.id in na else "unknown")
entry: dict[str, Any] = {"cat": u.cat, "mode": mode, "optional": u.id in optional}
if u.id in m.umbrella and not a.manifest_only:
entry["expect"] = "unknown" # 伞状单元:附件删了一部分,算不算问题看具体条目,评分不算
manifest_units[u.id] = entry
continue
if u.id in m.unit_defects:
entry.update({"expect": "defect", "reviewed": False, **m.unit_defects[u.id]})
elif mode == "implicit":
entry.update({"expect": "ok", "trap": "T06"})
elif u.id in (spec.get("traps") or {}):
entry.update({"expect": "ok", "trap": spec["traps"][u.id]})
else:
entry["expect"] = {"na": "na", "unknown": "unknown"}.get(mode, "ok")
manifest_units[u.id] = entry
manifest = {
"bid": bid,
"tender": tender,
"style": spec["style"],
"split": a.split,
"kind": "clean" if a.manifest_only else "sloppy",
"seed": a.seed,
"density": None if a.manifest_only else a.density,
"units": manifest_units,
"rule_defects": m.rule_defects,
}
d = OUT / bid
d.mkdir(parents=True, exist_ok=True)
spec["bid"] = bid
(d / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
(d / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
n_def = sum(1 for e in manifest_units.values() if e.get("expect") == "defect")
by: dict[str, int] = {}
for e in manifest_units.values():
if e.get("expect") == "defect":
by[e["defect"]] = by.get(e["defect"], 0) + 1
print(f"{bid}:要求项问题 {n_def} 个 {by},规则问题 {len(m.rule_defects)} 个")
if __name__ == "__main__":
main()