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>
This commit is contained in:
@@ -6,7 +6,10 @@
|
||||
- 词库:开发集用 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。
|
||||
- 响应方式按单元在投标文件里的响应位置推出来:逐条响应表 → explicit,只列偏离的表 → implicit,附件 → evidence,格式文件 → presence;
|
||||
风格 C 的叙述段落(form: narr)→ explicit(招标文件要求逐项响应,只是写成了叙述)。
|
||||
- 风格 C:技术响应在叙述段落里,按段注入(漏写一段 / 连续几段、数值改错、写明不支持、只说满足不给值、段落张冠李戴),
|
||||
负偏离未说明加在技术偏离表的总述行后面;其余(附件、规则问题)和风格 A / B 相同。开发集(A / B)的注入路径不变。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +28,7 @@ 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 rmb_upper # 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"}
|
||||
@@ -35,6 +38,7 @@ 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)}
|
||||
|
||||
|
||||
@@ -45,6 +49,7 @@ class Mutator:
|
||||
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 = [
|
||||
@@ -52,7 +57,12 @@ class Mutator:
|
||||
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" for u in b.get("responds") or []}
|
||||
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]]:
|
||||
@@ -160,6 +170,8 @@ class Mutator:
|
||||
|
||||
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)
|
||||
@@ -277,15 +289,17 @@ class Mutator:
|
||||
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 tp.get("value"):
|
||||
# 数值参数不会写「暂不支持」:写一个不满足的值并标负偏离
|
||||
v = Decimal(str(tp["value"]))
|
||||
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:
|
||||
req_text = row["cells"][req] if req is not None 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:
|
||||
@@ -346,6 +360,155 @@ class Mutator:
|
||||
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(
|
||||
@@ -425,6 +588,13 @@ class Mutator:
|
||||
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)
|
||||
@@ -449,6 +619,10 @@ def unit_modes(spec: dict[str, Any]) -> dict[str, str]:
|
||||
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")
|
||||
@@ -483,7 +657,24 @@ def main() -> None:
|
||||
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:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user