From b2b85843215e031679a6353194b6c5106346f7af Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Wed, 4 Nov 2026 18:58:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(evals):=20=E7=95=99=E5=87=BA=E9=9B=86?= =?UTF-8?q?=E7=94=9F=E6=88=90=E2=80=94=E2=80=94=E9=A3=8E=E6=A0=BC=20C?= =?UTF-8?q?=E3=80=81=E7=95=99=E5=87=BA=E9=9B=86=E6=94=B9=E5=86=99=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=EF=BC=9B=E8=AF=84=E6=B5=8B=E5=8F=AF=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E7=A1=AE=E8=AE=A4=E5=93=8D=E5=BA=94=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/architecture/check-eval.md | 6 +- evals/check/build.py | 204 ++++++++++++++++++++++++++++++- evals/check/export_rewrite.py | 9 +- evals/check/mutate.py | 207 ++++++++++++++++++++++++++++++-- evals/check/run_check.py | 5 + 5 files changed, 416 insertions(+), 15 deletions(-) diff --git a/docs/architecture/check-eval.md b/docs/architecture/check-eval.md index 8d38244..3842b4e 100644 --- a/docs/architecture/check-eval.md +++ b/docs/architecture/check-eval.md @@ -224,7 +224,11 @@ **读法**:按单元格分行和上一轮基本持平(召回 -1、致命 +1、误报略少),在一次模型重跑的波动范围内,结构上更对,保留。D09 少的 2 条是响应错位判断依赖模型引用「照抄的要求格」,模型换了引用就不触发——它是补漏,不是主力。业绩条款的修正带来 D13 +1、D01 +1;规矩的投标文件上多了 3 个提示(新查的业绩评分项)。 -## 10. 下一步 +## 10. 第 3 步冻结(2026-11-04) + +查标代码冻结在 `a60d16e`(规则集 `2026.11-r2`、提示词 `check.match` v2)。之后跑留出集(S03、S06、S09,风格 B 规矩 + 风格 C 马虎)和国动真实投标文件各一次,结果只记录,不据此调规则和提示词(GUIDELINES §8);留出集上发现的生成器错误按「响应清单错误」单独记数。 + +## 11. 下一步 1. 分项报价表和采购需求清单逐项对照(P13)**暂缓**:在开发集 7 份招标文件上试读「名称 + 数量」的采购清单表,S02、S08(服务类)没有这种表;S04 把「是否进口」一栏读成了名称;S10 采购包 2 的表跨页、第二页没有表头,漏了大半;S01 9 行里能和投标文件的分项对上名称的只有 7 行。P13 是致命级,按这个抽取质量在真实文件上会大量误报「缺项」,评测集里也没有注入过,先不上,覆盖范围里照写「还没有上线」。散文写法的单价上限同样暂缓。 2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。 diff --git a/evals/check/build.py b/evals/check/build.py index 9037c51..1cf2298 100644 --- a/evals/check/build.py +++ b/evals/check/build.py @@ -1,10 +1,14 @@ """从「招标文件档案」和读标清单生成规矩投标文件的结构化稿 spec.json(evals/check/GUIDELINES.md §1、§5)。 - cd server && ../.venv/bin/python ../evals/check/build.py <招标文件> <投标文件编号> --style A|B [--checklist fix3] + cd server && ../.venv/bin/python ../evals/check/build.py <招标文件> <投标文件编号> --style A|B|C [--checklist fix3] 规矩靠构造保证:偏离表、响应表、报价表、证明材料由脚本生成;技术参数有 op / value / unit 的,按要求算一个满足的值。 LLM 只写散文(档案里的 prose)和 B / C 风格的改写(rewrite.json,可选)。 +风格 C(叙述型,只用在留出集):技术要求不逐条列表,写在叙述性的技术方案里 +(<投标文件>/narrative.json,见 narrative()),技术偏离表只留一行总述(注入时再往里加偏离行); +档案里的 prose 不用,由 narrative.json 的各节替代(prose_of 指明替代哪一节)。 + ## 招标文件档案 evals/out/check/tenders/.json(照着招标原文整理,不进 git) { @@ -120,6 +124,124 @@ def comply(spec: dict[str, Any], positive: bool) -> tuple[str, str]: return f"支持{param}" if param else "满足", "无偏离" +def value_text(raw: dict[str, Any]) -> str | None: + """数值要求的满足值(不带参数名),风格 C 的正文用它填 {v};和 comply() 的非正偏离取值相同。""" + if raw.get("op") not in (">=", "<=", "=") or not raw.get("value"): + return None + try: + v = Decimal(str(raw["value"])) + except Exception: # noqa: BLE001 + return None + return f"{v.normalize():f}{raw.get('unit') or ''}" + + +CN_DIGITS = "一二三四五六七八九" + + +def cn(k: int) -> str: + """1 → 一,12 → 十二,30 → 三十(风格 C 的条目编号)。""" + tens, ones = divmod(k, 10) + head = "" if tens == 0 else ("十" if tens == 1 else CN_DIGITS[tens - 1] + "十") + return head + (CN_DIGITS[ones - 1] if ones else "") + + +def narr_head(j: int, label: str) -> str: + return f"({cn(j)}){label} " + + +def narrative( + tender: str, + narr: dict[str, Any], + chapter: int, + items: dict[str, dict[str, Any]], + sent_units: dict[str, list[str]], + form_responds: dict[str, list[str]], + required: set[str], + extra_units: set[str], +) -> list[dict[str, Any]]: + """风格 C 的技术服务方案:一节一个小标题,每段响应一条或几条要求(iids),或补写清单没抽到的标注单元(units)。 + 段落带 responds / req / tp / val / tmpl,注入时(mutate.py)按段改写; + required 里的条目和 extra_units 必须都写到。""" + gold_units, _ = gold.units(tender) + by_sid = {gold.sid(x): x for x in gold.sentences(tender)} + gold_by_id = {u.id: u for u in gold_units} + blocks: list[dict[str, Any]] = [ + {"type": "heading", "text": f"{chapter}.{narr['chapter_title']}", "new_page": True} + ] + seen_iids: list[str] = [] + seen_units: set[str] = set() + for k, sec in enumerate(narr["sections"], start=1): + blocks.append({"type": "heading", "level": 3, "text": f"{chapter}.{k} {sec['title']}"}) + prose_units = sorted({u for t in sec.get("prose_of") or [] for u in form_responds.get("prose:" + t, [])}) + if sec.get("intro"): + blocks.append({"type": "para", "text": sec["intro"], "responds": prose_units, "form": "prose"}) + elif prose_units: + raise SystemExit(f"{sec['title']}:替代了档案里的散文节,要写 intro 承接它的标注单元") + tab = sec.get("table") + if tab: + seen_iids += tab.get("iids") or [] + blocks.append( + { + "type": "table", + "title": tab.get("title"), + "header": tab["header"], + "rows": [{"cells": r, "anchor": 1} for r in tab["rows"]], + } + ) + for j, p in enumerate(sec["paras"], start=1): + iids = p.get("iids") or [] + seen_iids += iids + units = sorted( + {u for i in iids for s in items[i].get("sentences") or [] for u in sent_units.get(s, [])} + | set(p.get("units") or []) + ) + seen_units |= set(p.get("units") or []) + raw = ((items[iids[0]].get("raw") or {}).get("tech_param") or {}) if len(iids) == 1 else {} + val = value_text(raw) + tmpl = p["text"] + if (val is None) == ("{v}" in tmpl): + raise SystemExit(f"{sec['title']} / {p['label']}:{{v}} 只能也必须用在单条数值要求上({iids})") + body = tmpl.replace("{v}", val or "") + if iids: + req, req_text = items[iids[0]]["req"], "".join(items[i]["text"] or items[i]["title"] for i in iids) + elif not p.get("units"): # 只是叙述(设计思路、选型说明),不对应具体要求 + req, req_text = None, "" + else: + sents = sorted( + (by_sid[x] for u in p["units"] for x in gold_by_id[u].sids if x in by_sid), + key=lambda x: (x.page_no, x.seq), + ) + req = {"p": sents[0].page_no, "t": gold.norm(sents[0].text)[:24]} + req_text = "".join(x.text for x in sents) + head = narr_head(j, p["label"]) + blocks.append( + { + "type": "para", + "text": head + body, + "form": "narr", + "sec": k, + "label": p["label"], + "head": head, + "body": body, + "tmpl": tmpl, + "val": val, + "tp": raw, + "iids": iids, + "responds": units, + "req": req, + "req_text": req_text, + } + ) + dup = sorted({i for i in seen_iids if seen_iids.count(i) > 1}) + missing = sorted(required - set(seen_iids), key=lambda x: int(x[1:])) + missing_units = sorted(extra_units - seen_units) + if dup or missing or missing_units: + raise SystemExit(f"narrative.json:重复 {dup},漏写条目 {missing},漏写单元 {missing_units}") + for b in blocks: + b["vol"] = "v1" + return blocks + + def load_items(tender: str, run: str) -> dict[str, dict[str, Any]]: rows = json.loads((EVALS / "out" / "runs" / run / tender / "items.json").read_text()) by_sid = {gold.sid(s): s for s in gold.sentences(tender)} @@ -187,12 +309,36 @@ def _row(k: int, r: dict[str, Any], columns: list[str]) -> dict[str, Any]: } +# 复述要求时去掉开头的编号和标记(「1.」「(2)」「3、」「1.2.3」「★」);「2路服务器」开头的数字是内容,不去掉 +def c_short(r: dict[str, Any]) -> dict[str, Any]: + """风格 C 的偏离表行:要求写成「设备 + 参数」的简称,不复述原句;响应只写值或「具备……」。""" + tp = r.get("tp") or {} + label = (tp.get("device") or "") + (tp.get("param") or "") + numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value") + return { + **r, + "requirement": label or LEADING.sub("", r["requirement"])[:16], + "answer": r["answer"] if numeric else (f"具备{tp['param']}" if tp.get("param") else "满足"), + } + + +LEADING = re.compile( + r"^[\s★▲**]*(?:(?:[((]\d{1,3}[))]|\d{1,3}[.、.))](?!\d)|\d+(?:\.\d+){2,}|\d+\.\d+(?=\s))\s*)?[\s★▲**]*" +) + + def build(tender: str, bid: str, style: str, run: str) -> None: profile = json.loads((OUT / "tenders" / f"{tender}.json").read_text()) items = load_items(tender, run) sent_units = unit_of(tender) rewrite_path = OUT / bid / "rewrite.json" rewrite = json.loads(rewrite_path.read_text()) if rewrite_path.exists() else {} + narr = json.loads((OUT / bid / "narrative.json").read_text()) if style == "C" else {} + # 风格 C 只在技术偏离表逐条响应时把技术要求写进叙述正文;默认接受的(只列偏离)照旧只列偏离 + tech_explicit = any( + f["kind"] == "response_table" and f.get("route", "tech_row") == "tech_row" and not f.get("only_deviations") + for f in profile["forms"] + ) proj = profile["project"] routes: dict[str, str] = profile.get("routes", {}) @@ -220,9 +366,9 @@ def build(tender: str, bid: str, style: str, run: str) -> None: if numeric: answer, dev = comply(raw, positive) else: # 功能类要求:复述型(风格 A)照常写「满足,……」 - answer, dev = "满足," + re.sub(r"^[\s★▲**\d.、()()]+", "", req_text)[:120], "无偏离" + answer, dev = "满足," + LEADING.sub("", req_text)[:120], "无偏离" rw = dict(rewrite.get(iid) or {}) - if "answer" in rw and NUMS.findall(rw["answer"]) != NUMS.findall(answer): + if "answer" in rw and NUMS.findall(rw["answer"]) != NUMS.findall(answer) and (style != "C" or numeric): rw.pop("answer") # 改写后的数字和规矩的响应对不上:丢弃改写,规矩靠构造保证 rows_by_route.setdefault(route, []).append( { @@ -251,9 +397,13 @@ def build(tender: str, bid: str, style: str, run: str) -> None: # 读标清单漏抽、标注集里有的技术参数:也按规矩响应,免得规矩的投标文件里「无意漏响应」 gold_units, _ = gold.units(tender) by_sid = {gold.sid(x): x for x in gold.sentences(tender)} + extra_units: set[str] = set() # 风格 C:这些单元由 narrative.json 的段落补写 for u in gold_units: if u.cat != "tech_param" or u.id in covered or u.id in na_units or not u.sids: continue + if style == "C" and tech_explicit: + extra_units.add(u.id) + continue sents = sorted((by_sid[k] for k in u.sids if k in by_sid), key=lambda x: (x.page_no, x.seq)) if not sents: continue @@ -359,6 +509,34 @@ def build(tender: str, bid: str, style: str, run: str) -> None: {"type": "table", "header": header, "rows": rows, "price_items": True}, {"type": "para", "text": f"合计:{ctx['total']}元({ctx['total_cn']})"}, ] + elif ( + kind == "response_table" + and style == "C" + and f.get("route", "tech_row") == "tech_row" + and not f.get("only_deviations") + ): + # 风格 C:技术偏离表只留总述行(技术响应都在叙述性的技术方案里),注入的偏离行由 mutate.py 加 + header = f.get("header") or ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"] + columns = columns_of(header) + rows = [] + for k, r in enumerate(narr["tech_table_rows"], 1): + value = {"no": str(k), **r} + rows.append({"cells": [value.get(c, "") for c in columns], "anchor": columns.index("answer")}) + blocks += [ + head, + { + "type": "table", + "header": header, + "rows": rows, + "form": f["key"], + "route": "tech_row", + "only_deviations": False, + "mode": None, + "columns": columns, + "pool": [], + "summary": True, + }, + ] elif kind == "response_table": src = rows_by_route.get(f.get("route", "tech_row"), []) header = f.get("header") or ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"] @@ -379,8 +557,12 @@ def build(tender: str, bid: str, style: str, run: str) -> None: } ] pool = [ - _row(0, r, columns) for r in src if r["deviation"] == "无偏离" + _row(0, c_short(r) if style == "C" else r, columns) for r in src if r["deviation"] == "无偏离" ] # 没列出的(默认接受):注入时可以挑来写成偏离 + if style == "C": # 风格 C 的行只写要求简称,原文留给注入时判断要求类型 + texts = [r["requirement"] for r in src if r["deviation"] == "无偏离"] + for row, text in zip(pool, texts, strict=True): + row["req_text"] = text else: rows = [_row(k, r, columns) for k, r in enumerate(src, 1)] pool = [] @@ -393,6 +575,9 @@ def build(tender: str, bid: str, style: str, run: str) -> None: prefix = "技" if f.get("route", "tech_row") == "tech_row" else "商" for k, r in enumerate(rows, 1): r["cells"][columns.index("no")] = f"{prefix}-{k:02d}" + if style == "C" and "no" in columns: # 风格 C 用自己的编号 + for k, r in enumerate(rows, 1): + r["cells"][columns.index("no")] = f"({cn(k)})" blocks += [ head, { @@ -451,7 +636,12 @@ def build(tender: str, bid: str, style: str, run: str) -> None: "vol": "v1", }, ] - for sec in profile.get("prose", []): + if style == "C": + required = {iid for iid in items if "tech_row" in routes.get(iid, "na").split("+")} if tech_explicit else set() + blocks += narrative( + tender, narr, len(profile["forms"]) + 1, items, sent_units, form_responds, required, extra_units + ) + for sec in profile.get("prose", []) if style != "C" else []: blocks.append({"type": "heading", "text": sec["title"], "new_page": True}) units = sorted(set(form_responds.get("prose:" + sec["title"], []) + form_responds.get("prose:", []))) blocks += [ @@ -477,6 +667,10 @@ def build(tender: str, bid: str, style: str, run: str) -> None: else: volumes = [{"id": "v1", "kind": "whole", "header": None, "blocks": blocks}] traps = {u: "T01" for r in moved for u in r.get("responds") or []} + for b in blocks: # 风格 C:叙述里换了说法、合并概括后再响应(T02) + if b.get("form") == "narr": + for u in b["responds"]: + traps.setdefault(u, "T02") for v in rewrite: # 被改写的要求(截断、同义替换)是 T02;只写值不复述是 T03 for r in [x for xs in rows_by_route.values() for x in xs if x.get("key") == v]: for u in r["responds"]: diff --git a/evals/check/export_rewrite.py b/evals/check/export_rewrite.py index 838aad1..29cd38b 100644 --- a/evals/check/export_rewrite.py +++ b/evals/check/export_rewrite.py @@ -2,6 +2,7 @@ cd server && ../.venv/bin/python ../evals/check/export_rewrite.py <招标文件> [...] 输出:evals/out/check/<招标文件>-B/rewrite-input.json(数组,字段 key、requirement、answer)。 +没有风格 A 稿时(留出集)从还没改写的风格 B 稿取。 """ from __future__ import annotations @@ -13,7 +14,13 @@ import sys OUT = pathlib.Path(__file__).resolve().parents[1] / "out" / "check" for tender in sys.argv[1:]: - spec = json.loads((OUT / f"{tender}-A" / "spec.json").read_text()) + # 留出集没有风格 A:从还没改写的风格 B 稿取(表格行的要求 / 响应两栏和 A 一样);B 已经改写过就不能再取 + src = OUT / f"{tender}-A" + if not (src / "spec.json").exists(): + src = OUT / f"{tender}-B" + if (src / "rewrite.json").exists(): + sys.exit(f"{tender}:没有风格 A 稿,风格 B 稿已经按 rewrite.json 改写过,不能再导出改写输入") + spec = json.loads((src / "spec.json").read_text()) rows: dict[str, dict[str, str]] = {} for v in spec["volumes"]: for b in v["blocks"]: diff --git a/evals/check/mutate.py b/evals/check/mutate.py index c379aca..b6a9c26 100644 --- a/evals/check/mutate.py +++ b/evals/check/mutate.py @@ -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) diff --git a/evals/check/run_check.py b/evals/check/run_check.py index fc8d329..d85b833 100644 --- a/evals/check/run_check.py +++ b/evals/check/run_check.py @@ -186,6 +186,7 @@ async def main() -> None: ap.add_argument("--rules-only", action="store_true", help="只重跑规则检查,写回已有运行的 results.json") ap.add_argument("--no-direct", action="store_true", help="不做偏离栏直读(对比用)") ap.add_argument("--no-mismatch", action="store_true", help="不做响应错位判断(对比用)") + ap.add_argument("--modes", help="人工确认后的响应方式,如 technical=implicit,commercial=implicit") args = ap.parse_args() d = OUT / args.bid manifest = json.loads((d / "manifest.json").read_text()) @@ -246,6 +247,10 @@ async def main() -> None: decisions: dict[UUID, applicability.Decision] = {} if not args.no_modes: modes = response_mode.detect(gold.sentences(manifest["tender"])) + for pair in filter(None, (args.modes or "").split(",")): + cat, mode = pair.split("=") # 模拟清单页上人工确认 / 改过响应方式(C12 的人工确认点) + m = modes[cat] + modes[cat] = response_mode.Mode(cat, mode, "found", m.explicit, m.implicit) decisions = {i: applicability.decide(p, modes) for i, p in items.items()} items = {i: p for i, p in items.items() if decisions[i].match} results, stats = await match.run(ctx, uuid4(), items, docs, progress)