Files
bid-doctor/evals/check/mutate.py
YANG JIANKUAN db1b1f6f9f feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行
- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:22 +08:00

546 lines
28 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):
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
# ---------- 规则问题 ----------
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 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("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:
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()