Files
bid-doctor/evals/check/mutate.py
YANG JIANKUAN cb0c2fcafe 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>
2026-11-04 14:25:14 +08:00

537 lines
27 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。
"""
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 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}(方案|证明|报告|文件|承诺|截图|复印件|证书)")
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.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" 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:
"""把这一行改过的响应、偏离、说明照样写到同一要求的其他行上。"""
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 tp.get("value"):
# 数值参数不会写「暂不支持」:写一个不满足的值并标负偏离
v = Decimal(str(tp["value"]))
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 ""
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):
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
# ---------- 规则问题 ----------
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", "投标人名称空", b["text"][:24])
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 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("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) # noqa: S311 评测数据,不用于安全
if 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()