From a07663cbdb6d56a931be678824891c5571386407 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Wed, 4 Nov 2026 12:18:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(evals):=20=E6=9F=A5=E6=A0=87=E8=AF=84?= =?UTF-8?q?=E6=B5=8B=E9=9B=86=E7=94=9F=E6=88=90=E5=99=A8=E3=80=81=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E3=80=81=E8=AF=84=E5=88=86=E5=92=8C=E5=8F=AA=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.py:从「招标文件档案」和读标清单生成规矩投标文件稿;技术参数按 op / value / unit 算满足的值, 分项报价按分项上限比例分摊,落款日期取截止日前 3 天,偏离表按格式表头决定列(含设备名称列), 读标漏抽的技术参数也补上响应;routes 支持「+」组合和 prose 散文响应。 - mutate.py:按词库注入要求项问题(单点 / 连续块,带不带标志词)和规则问题,写响应清单; 符合性条件响应表的问题按表级记一个。 - score.py:按单元去重、类型族比对、读标漏抽单列、端到端召回、无法对照单列。 - run_check.py:加只检索不调模型的基线;render.py:扫描件按最后一次出现定位。 - vocab-dev.json:开发集注入词库(留出集的另行封存,不进 git)。 Co-Authored-By: Claude Opus 5.5 --- evals/check/build.py | 445 +++++++++++++++++++++++++++++++++++++ evals/check/mutate.py | 390 ++++++++++++++++++++++++++++++++ evals/check/render.py | 11 +- evals/check/run_check.py | 27 ++- evals/check/score.py | 183 +++++++++++++++ evals/check/vocab-dev.json | 21 ++ 6 files changed, 1070 insertions(+), 7 deletions(-) create mode 100644 evals/check/build.py create mode 100644 evals/check/mutate.py create mode 100644 evals/check/score.py create mode 100644 evals/check/vocab-dev.json diff --git a/evals/check/build.py b/evals/check/build.py new file mode 100644 index 0000000..c86bd4b --- /dev/null +++ b/evals/check/build.py @@ -0,0 +1,445 @@ +"""从「招标文件档案」和读标清单生成规矩投标文件的结构化稿 spec.json(evals/check/GUIDELINES.md §1、§5)。 + + cd server && ../.venv/bin/python ../evals/check/build.py <招标文件> <投标文件编号> --style A|B [--checklist fix3] + +规矩靠构造保证:偏离表、响应表、报价表、证明材料由脚本生成;技术参数有 op / value / unit 的,按要求算一个满足的值。 +LLM 只写散文(档案里的 prose)和 B / C 风格的改写(rewrite.json,可选)。 + +## 招标文件档案 evals/out/check/tenders/.json(照着招标原文整理,不进 git) + +{ + "tender": "S02", + "project": {"name": "…", "code": "…", "purchaser": "…", "agency": "…", "deadline": "2026-12-01", + "validity_days": 90, "ceiling_total": "1514100.00", "lots": [{"no": "1", "ceiling": "…"}]}, + "modes": [{"category": "tech_param", "scope": "一般参数", "mode": "implicit", "when": null, + "basis": [{"p": 76, "t": "否则将认为投标人接受"}]}], + "forms": [ // 按格式章的顺序 + {"key": "bid_letter", "title": "投标函", "kind": "text", "paragraphs": ["致:{agency}", "…{project_name}…"]}, + {"key": "price_summary", "title": "开标一览表", "kind": "price_summary", "header": ["项目名称", "投标总价(元)", "…"]}, + {"key": "price_items", "title": "分项价格表", "kind": "price_items", "header": ["序号", "名称", "…"], + "items": [{"name": "汇聚交换机", "qty": 3, "unit": "台"}]}, + {"key": "tech_dev", "title": "偏离表", "kind": "response_table", "route": "tech_row", + "header": ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"], "only_deviations": true}, + {"key": "compliance", "title": "采购需求符合性条件响应表", "kind": "fixed_rows", "header": ["…"], + "rows": [["1", "“(一)数量要求”的响应情况", "见招标文件第六章《采购需求》中相关要求", "完全响应", ""]]}, + {"key": "auth", "title": "授权委托书", "kind": "text", "paragraphs": ["…"]}, + {"key": "evidence", "title": "资格证明文件", "kind": "attachments"} + ], + "attachments": [{"key": "a1", "name": "营业执照(副本)", "scan": true, "lines": ["…"]}], + "routes": {"i12": "tech_row", "i40": "commercial_row", "i55": "attach:a1", "i60": "form:compliance", "i70": "na"}, + "prose": [{"title": "技术方案", "paragraphs": ["…"]}] +} + +routes 的取值可以用「+」连起来(tech_row+attach:a3),「prose」表示靠技术方案散文响应(prose:<节标题> 指定哪一节)。 +占位符:{bidder} {credit_code} {legal_rep} {agent} {project_name} {project_code} {purchaser} {agency} {date} +{validity_days} {total} {total_cn}。`routes` 的键是读标清单里正式条目的序号(items.json 的下标,前缀 i)。 +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import pathlib +import re +import sys +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + +EVALS = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EVALS)) +import gold # noqa: E402 + +OUT = EVALS / "out" / "check" +BIDDER = { + "bidder": "江苏启衡信息技术有限公司(虚构)", + "credit_code": "91320100MA1XXXXX0Q", + "legal_rep": "周明远", + "agent": "陈立新", + "date": "2026年11月20日", +} +DIGITS = "零壹贰叁肆伍陆柒捌玖" +UNITS = ["", "拾", "佰", "仟"] +SECTIONS = ["", "万", "亿"] + + +def rmb_upper(amount: Decimal) -> str: + """金额大写(元、角、分),用于开标一览表。""" + q = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + yuan, cents = divmod(int(q * 100), 100) + if yuan == 0: + head = "零元" + else: + groups, out = [], "" + while yuan: + groups.append(yuan % 10000) + yuan //= 10000 + for gi in range(len(groups) - 1, -1, -1): + g = groups[gi] + if g == 0: + if out and not out.endswith("零"): + out += "零" + continue + part, zero = "", False + for di in range(3, -1, -1): + d = (g // 10**di) % 10 + if d == 0: + zero = bool(part) + continue + if zero: + part += "零" + zero = False + part += DIGITS[d] + UNITS[di] + if out and g < 1000 and not out.endswith("零"): + out += "零" + out += part + SECTIONS[gi] + head = out.rstrip("零") + "元" + jiao, fen = divmod(cents, 10) + if not cents: + return head + "整" + return head + (DIGITS[jiao] + "角" if jiao else "零") + (DIGITS[fen] + "分" if fen else "") + + +def fill(text: str, ctx: dict[str, str]) -> str: + return re.sub(r"\{(\w+)\}", lambda m: ctx.get(m.group(1), m.group(0)), text) + + +def comply(spec: dict[str, Any], positive: bool) -> tuple[str, str]: + """按要求算一个满足的响应值。返回 (响应文字, 偏离)。positive 时给正偏离(T05)。""" + param, op, value, unit = (spec.get(k) or "" for k in ("param", "op", "value", "unit")) + try: + v = Decimal(str(value)) + except Exception: # noqa: BLE001 + v = None + if v is not None and op in (">=", "<="): + better = (v * 2 if op == ">=" else v / 2).normalize() if positive else v.normalize() + return f"{param}:{better:f}{unit}", "正偏离" if positive else "无偏离" + if v is not None and op == "=": + return f"{param}:{v.normalize():f}{unit}", "无偏离" + return f"支持{param}" if param else "满足", "无偏离" + + +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)} + out = {} + for i, r in enumerate(rows): + if r.get("zone") != "formal": + continue + sents = [by_sid[k] for k in r.get("sentences") or [] if k in by_sid] + out[f"i{i}"] = { + **r, + "text": "".join(s.text for s in sents), + "req": {"p": sents[0].page_no, "t": gold.norm(sents[0].text)[:24]} if sents else None, + } + return out + + +def unit_of(tender: str) -> dict[str, str]: + """清单条目 → 标注单元:读标清单条目的句子和标注单元的句子有重叠就对上(和读标评分同一口径)。""" + units, _ = gold.units(tender) + by_sent: dict[str, list[str]] = {} + for u in units: + for s in u.sids: + by_sent.setdefault(s, []).append(u.id) + return by_sent # 句子编号 → 单元编号 + + +COLUMN_HINTS = [ # 表头文字 → 字段(按顺序匹配,先匹配到的为准) + ("no", ("序号", "序")), + ("deviation", ("偏离",)), + ("note", ("说明", "备注")), + ("answer", ("响应", "投标文件中的内容", "应答", "投标人", "实际")), + ("requirement", ("要求", "条款", "参数", "指标")), + ("device", ("名称", "设备", "货物", "服务")), +] + + +def columns_of(header: list[str]) -> list[str]: + """按格式的表头决定每一列放什么(S04 的偏离表第 2 列是「项目名称」)。认不出的列留空。""" + out: list[str] = [] + for h in header: + field = next((f for f, keys in COLUMN_HINTS if any(k in h for k in keys) and f not in out), "") + out.append(field) + if "answer" not in out and "" in out: + out[out.index("")] = "answer" + return out + + +def _row(k: int, r: dict[str, Any], columns: list[str]) -> dict[str, Any]: + value = { + "no": str(k), + "device": (r.get("tp") or {}).get("device") or "", + "requirement": r["requirement"], + "answer": r["answer"], + "deviation": r["deviation"], + "note": "", + } + return { + "cells": [value.get(c, "") for c in columns], + "anchor": columns.index("answer") if "answer" in columns else len(columns) - 1, + "responds": r["responds"], + "req": r["req"], + "iid": r["iid"], + "tp": r["tp"], + } + + +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 {} + proj = profile["project"] + routes: dict[str, str] = profile.get("routes", {}) + + rows_by_route: dict[str, list[dict[str, Any]]] = {} + attach_responds: dict[str, list[str]] = {} + form_responds: dict[str, list[str]] = {} + for iid, it in items.items(): + units = sorted({u for s in it.get("sentences") or [] for u in sent_units.get(s, [])}) + # 一条要求可以走几个去处,用「+」连起来(如 tech_row+attach:a3:表里一行,另附证明材料) + parts = routes.get(iid, "na").split("+") + for part in parts: + if part.startswith("attach:"): + attach_responds.setdefault(part[7:], []).extend(units) + elif part.startswith("form:"): + form_responds.setdefault(part[5:], []).extend(units) + elif part.startswith("prose"): + form_responds.setdefault("prose:" + part[6:], []).extend(units) + route = next((x for x in parts if x in ("tech_row", "commercial_row")), None) + if route is None: + continue + raw = (it.get("raw") or {}).get("tech_param") or {} + positive = style == "A" and len(rows_by_route.get(route, [])) % 9 == 4 # 每 9 行一个正偏离(T05) + req_text = it["text"] or it["title"] + numeric = raw.get("op") in (">=", "<=", "=") and raw.get("value") + if numeric: + answer, dev = comply(raw, positive) + else: # 功能类要求:复述型(风格 A)照常写「满足,……」 + answer, dev = "满足," + re.sub(r"^[\s★▲**\d.、()()]+", "", req_text)[:120], "无偏离" + rw = rewrite.get(iid) or {} + rows_by_route.setdefault(route, []).append( + { + "iid": iid, + "requirement": rw.get("requirement", req_text), + "answer": rw.get("answer", answer), + "deviation": dev, + "responds": units, + "req": it["req"], + "tp": raw, + } + ) + covered = {u for v in rows_by_route.values() for r in v for u in r["responds"]} + covered |= {u for v in attach_responds.values() for u in v} | {u for v in form_responds.values() for u in v} + na_units = sorted( + { + u + for iid, it in items.items() + if routes.get(iid, "na") == "na" + for s in it.get("sentences") or [] + for u in sent_units.get(s, []) + } + - covered + ) + # 读标清单漏抽、标注集里有的技术参数:也按规矩响应,免得规矩的投标文件里「无意漏响应」 + gold_units, _ = gold.units(tender) + by_sid = {gold.sid(x): x for x in gold.sentences(tender)} + 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 + 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 + text = "".join(x.text for x in sents) + rows_by_route.setdefault("tech_row", []).append( + { + "iid": None, + "requirement": text, + "answer": "满足:" + text, + "deviation": "无偏离", + "responds": [u.id], + "req": {"p": sents[0].page_no, "t": gold.norm(sents[0].text)[:24]}, + "tp": {}, + } + ) + + if proj.get("deadline"): # 落款日期取投标截止日前 3 天(写死的日期可能晚于截止日,规矩的投标文件不能带 P08) + dl = datetime.date.fromisoformat(str(proj["deadline"])[:10]) - datetime.timedelta(days=3) + bidder_date = f"{dl.year}年{dl.month}月{dl.day}日" + else: + bidder_date = BIDDER["date"] + total = Decimal(str(proj.get("ceiling_total") or "1000000")) * Decimal("0.93") + total = total.quantize(Decimal("1")) + ctx = { + **BIDDER, + "date": bidder_date, + "project_name": proj.get("name", ""), + "project_code": proj.get("code", ""), + "purchaser": proj.get("purchaser", ""), + "agency": proj.get("agency", ""), + "validity_days": str(proj.get("validity_days") or 90), + "total": f"{total:,.2f}", + "total_cn": rmb_upper(total), + } + + blocks: list[dict[str, Any]] = [{"type": "title", "text": f"{proj.get('name', '')}投标文件"}] + price_items = next((f.get("items") for f in profile["forms"] if f["kind"] == "price_items"), []) or [] + weights = [Decimal(str(x.get("qty") or 1)) for x in price_items] or [Decimal(1)] + # 各分项的合价:有分项 / 单价上限的按上限的比例分摊(总价是限价的 93%,所以每项都在上限以内);没有的按数量分摊 + ceilings = [Decimal(str(x["ceiling"])) if x.get("ceiling") else None for x in price_items] + if price_items and all(c is not None for c in ceilings): + base = [ + c * w if x.get("ceiling_is_unit") else c for c, w, x in zip(ceilings, weights, price_items, strict=False) + ] # type: ignore[operator] + else: + base = list(weights) + lines = [(total * b / sum(base)).quantize(Decimal("0.01")) for b in base] + lines[-1] = total - sum(lines[:-1]) + unit_prices = [(ln / w).quantize(Decimal("0.01")) for ln, w in zip(lines, weights, strict=False)] + for n, f in enumerate(profile["forms"], start=1): + head = {"type": "heading", "text": f"{n}.{f['title']}", "new_page": n > 1, "form": f["key"]} + kind = f["kind"] + if kind == "text": + blocks += [ + head, + *( + { + "type": "para", + "text": fill(p, ctx), + "responds": form_responds.get(f["key"], []), + "form": f["key"], + } + for p in f["paragraphs"] + ), + ] + blocks.append({"type": "sign", "lines": [f"投标人:{ctx['bidder']}(电子签章)", f"日期:{ctx['date']}"]}) + elif kind == "price_summary": + header = f.get("header") or ["项目名称", "投标总价(小写,元)", "投标总价(大写)"] + cells = [ctx["project_name"], ctx["total"], ctx["total_cn"]] + [""] * max(0, len(header) - 3) + blocks += [ + head, + { + "type": "table", + "header": header, + "rows": [{"cells": cells[: len(header)], "anchor": 1}], + "price_summary": True, + }, + ] + elif kind == "price_items": + rows = [] + for k, (x, up, ln) in enumerate(zip(price_items, unit_prices, lines, strict=False), start=1): + rows.append( + { + "cells": [ + str(k), + x["name"], + f"{x.get('qty', 1)}", + x.get("unit", ""), + f"{up:,.2f}", + f"{ln:,.2f}", + ], + "anchor": 1, + } + ) + header = ["序号", "名称", "数量", "单位", "单价(元)", "总价(元)"] + blocks += [ + head, + {"type": "table", "header": header, "rows": rows, "price_items": True}, + {"type": "para", "text": f"合计:{ctx['total']}元({ctx['total_cn']})"}, + ] + elif kind == "response_table": + src = rows_by_route.get(f.get("route", "tech_row"), []) + header = f.get("header") or ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"] + columns = columns_of(header) + if f.get("only_deviations"): + devs = [r for r in src if r["deviation"] != "无偏离"] + rows = [_row(k, r, columns) for k, r in enumerate(devs, 1)] + empty = {"no": "1", "requirement": "—", "answer": "无偏离", "deviation": "无"} + rows = rows or [{"cells": [empty.get(c, "") for c in columns], "anchor": columns.index("answer")}] + pool = [ + _row(0, r, columns) for r in src if r["deviation"] == "无偏离" + ] # 没列出的(默认接受):注入时可以挑来写成偏离 + else: + rows = [_row(k, r, columns) for k, r in enumerate(src, 1)] + pool = [] + blocks += [ + head, + { + "type": "table", + "header": header, + "rows": rows, + "form": f["key"], + "route": f.get("route", "tech_row"), + "only_deviations": bool(f.get("only_deviations")), + "columns": columns, + "pool": pool, + }, + ] + elif kind == "fixed_rows": + rows = [ + {"cells": [fill(c, ctx) for c in r], "responds": form_responds.get(f["key"], []), "anchor": len(r) - 2} + for r in f["rows"] + ] + blocks += [head, {"type": "table", "header": f["header"], "rows": rows, "form": f["key"], "fixed": True}] + elif kind == "attachments": + blocks.append(head) + for a in profile.get("attachments", []): + b = { + "type": "scan" if a.get("scan") else "para", + "text": fill(a["name"], ctx), + "responds": sorted(set(attach_responds.get(a["key"], []))), + "attach": a["key"], + } + if a.get("scan"): + b["lines"] = [fill(x, ctx) for x in a.get("lines", [])] + else: + b["text"] = fill(a["name"] + ":" + ";".join(a.get("lines", [])), ctx) + blocks.append(b) + else: + raise ValueError(f"未知的表单类型:{kind}") + for sec in profile.get("prose", []): + blocks.append({"type": "heading", "text": sec["title"], "new_page": True}) + units = sorted(set(form_responds.get("prose:" + sec["title"], []) + form_responds.get("prose:", []))) + blocks += [ + {"type": "para", "text": fill(p, ctx), "responds": units if i == 0 else [], "form": "prose"} + for i, p in enumerate(sec["paragraphs"]) + ] + + spec = { + "bid": bid, + "tender": tender, + "style": style, + "project_name": proj.get("name", ""), + "bidder": BIDDER, + "volumes": [{"id": "v1", "kind": "whole", "header": None, "blocks": blocks}], + "price": { + "total": f"{total:.2f}", + "items": [ + {"name": x["name"], "qty": x.get("qty", 1), "unit_price": f"{p:.2f}"} + for x, p in zip(price_items, unit_prices, strict=False) + ], + }, + "routes_used": {r: len(v) for r, v in rows_by_route.items()}, + "na_units": na_units, + "deadline": proj.get("deadline"), + "ceiling_total": proj.get("ceiling_total"), + "validity_days": proj.get("validity_days"), + } + d = OUT / bid + d.mkdir(parents=True, exist_ok=True) + (d / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1)) + print( + f"{bid}:{sum(len(v) for v in rows_by_route.values())} 行响应,{len(profile.get('attachments', []))} 份附件,总价 {ctx['total']}" + ) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("tender") + ap.add_argument("bid") + ap.add_argument("--style", default="A") + ap.add_argument("--checklist", default="fix3") + a = ap.parse_args() + build(a.tender, a.bid, a.style, a.checklist) diff --git a/evals/check/mutate.py b/evals/check/mutate.py new file mode 100644 index 0000000..475174a --- /dev/null +++ b/evals/check/mutate.py @@ -0,0 +1,390 @@ +"""往规矩的投标文件稿里注入问题,写出马虎的投标文件稿和响应清单(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"} +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.unit_defects: dict[str, dict[str, Any]] = {} + self.rule_defects: list[dict[str, Any]] = [] + self.blocks = spec["volumes"][0]["blocks"] + + # ---------- 定位 ---------- + 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 + if explicit is None or b["only_deviations"] != explicit: + out.append(b) + return out + + def free(self, units: list[str]) -> bool: + return bool(units) and not any(u in self.unit_defects 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 + for r in chunk: + self.mark(r.get("responds") or [], "D01", "删连续一段", "block", worded=False) + del rows[start : start + n] + 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 + + @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 [] + if op == "D01": + t["rows"].remove(r) + self.mark(units, "D01", "删一行", worded=False) + elif op == "D03": + return self._unmet(r, tp, ans, dev) + 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": + others = [ + x + for _, x in cands + if x is not r + and (x.get("tp") or {}).get("param") + 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) + 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 + + def implicit_deviation(self, n: int) -> None: + """只列偏离的表:挑默认接受的要求写成偏离(D06 负偏离未说明 / D05 写明不支持)。""" + for t in self.tables(explicit=False): + pool = [r for r in t.get("pool") or [] if self.free(r.get("responds") or [])] + 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]) + else: + param = (row.get("tp") or {}).get("param") or ( + row["cells"][req][:12] if req is not None else "该功能" + ) + 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]) -> None: + """删掉附件:资格条件 → D11,评分项 → D13,其他 → D02。""" + cands = [b for b in self.blocks if b.get("attach") and self.free(b.get("responds") or [])] + self.rng.shuffle(cands) + for b in cands[:n]: + units = b.get("responds") or [] + 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.blocks.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.spec["volumes"][0]["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.blocks.insert(letter + 1, {"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.blocks.insert(self.blocks.index(t) + 1, {"type": "para", "text": text}) + self.rule("P12", text, text) + + +def unit_modes(spec: dict[str, Any]) -> dict[str, str]: + modes: dict[str, str] = {} + for b in spec["volumes"][0]["blocks"]: + if b.get("type") == "table" and "only_deviations" in b: + mode = "implicit" if b["only_deviations"] else "explicit" + for r in [*b["rows"], *(b.get("pool") or [])]: + for u in r.get("responds") or []: + modes.setdefault(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} + 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 评测数据,不用于安全 + if not a.manifest_only: + if a.density == "high": + m.d01_block() + m.row_ops(18) + m.implicit_deviation(4) + m.attachments(4, cats) + 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) + 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.unit_defects: + entry.update({"expect": "defect", "reviewed": False, **m.unit_defects[u.id]}) + elif mode == "implicit": + entry.update({"expect": "ok", "trap": "T06"}) + 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() diff --git a/evals/check/render.py b/evals/check/render.py index 00f12be..d02d6f3 100644 --- a/evals/check/render.py +++ b/evals/check/render.py @@ -132,12 +132,11 @@ def block_text(b: dict[str, Any], row: dict[str, Any] | None = None) -> str: return str(b.get("text") or " ".join(b.get("lines") or [])) -def locate(texts: dict[int, str], snippet: str) -> int | None: +def locate(texts: dict[int, str], snippet: str, last: bool = False) -> int | None: + """last:取最后一次出现的页(附件在文件末尾,名称常和前面格式文件的标题相同)。""" q = text_layer.norm(snippet)[:SNIPPET] - for p, t in texts.items(): - if q and q in t: - return p - return None + hits = [p for p, t in texts.items() if q and q in t] + return (hits[-1] if last else hits[0]) if hits else None def render(bid: str) -> None: @@ -151,7 +150,7 @@ def render(bid: str) -> None: page_html, header = volume_html(spec, vol) pdf = render_pdf(page_html, header) texts = page_texts(pdf) - scans = {p for b in vol["blocks"] if b.get("type") == "scan" and (p := locate(texts, b["text"]))} + scans = {p for b in vol["blocks"] if b.get("type") == "scan" and (p := locate(texts, b["text"], last=True))} located: list[tuple[list[str], str]] = [] for b in vol["blocks"]: if b.get("type") == "table": diff --git a/evals/check/run_check.py b/evals/check/run_check.py index 405218c..77c8770 100644 --- a/evals/check/run_check.py +++ b/evals/check/run_check.py @@ -14,6 +14,7 @@ import asyncio import hashlib import json import pathlib +import re import sys from typing import Any from uuid import UUID, uuid4 @@ -66,6 +67,29 @@ async def cached_run_json( return data["data"] +ITEM = re.compile(r"^\[(I\d+)\] ") +CANDS = re.compile(r"^候选句:(.*)$") +FRAG = re.compile(r"^\[(d\d+p\d+s\d+)\]([^)]*)(.*)$") + + +async def baseline_run_json( + ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str +) -> dict[str, Any]: + """只检索基线(GUIDELINES §7):不调模型,检索第一名就当作已响应。""" + del ctx, job_type, job_id, task, prompt_id, version + frags = {m.group(1): m.group(2) for line in user.splitlines() if (m := FRAG.match(line.strip()))} + results, key = [], None + for line in user.splitlines(): + if m := ITEM.match(line.strip()): + key = m.group(1) + elif (m := CANDS.match(line.strip())) and key: + first = m.group(1).split("、")[0] + results.append( + {"item": key, "verdict": "responded", "evidence": [{"sid": first, "quote": frags.get(first, "")}]} + ) + return {"results": results} + + def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]: """读标评测输出的正式清单 → 查标的清单项(和检查快照的 payload 同样的字段)。""" rows = json.loads((EVALS / "out" / "runs" / run / tender / "items.json").read_text()) @@ -94,6 +118,7 @@ async def main() -> None: ap.add_argument("bid") ap.add_argument("--checklist", default="fix3") ap.add_argument("--name", default="base") + ap.add_argument("--baseline", action="store_true", help="只检索、不调模型的基线") args = ap.parse_args() d = OUT / args.bid manifest = json.loads((d / "manifest.json").read_text()) @@ -102,7 +127,7 @@ async def main() -> None: for n, pdf in enumerate(sorted(d.glob("v*.pdf")), start=1): pages = text_layer.extract(f"{args.bid}-{pdf.stem}", pdf.read_bytes()) docs.append(match.BidDoc(uuid4(), n, pdf.stem, text_layer.body_sentences(pages))) - ai_service.run_json = cached_run_json # type: ignore[assignment] + ai_service.run_json = baseline_run_json if args.baseline else cached_run_json # type: ignore[assignment] async def progress(_: float) -> None: return None diff --git a/evals/check/score.py b/evals/check/score.py new file mode 100644 index 0000000..fc0cecb --- /dev/null +++ b/evals/check/score.py @@ -0,0 +1,183 @@ +"""查标评分:对照响应清单给一次离线检查打分(evals/check/GUIDELINES.md §7)。 + + cd server && ../.venv/bin/python ../evals/check/score.py <投标文件> [--run base] [--include-unreviewed] + +- 清单项 → 标注单元:清单项的招标侧句子和单元的句子有重叠就对上(和读标评分同一口径); +- 按单元去重:同一要求在清单里有几条,只要一条报出就算召回,几条都报不算误报; +- 问题类型按类型族比(D01 / D02 / D09 / D11 / D13 → unresponded,D03 / D07 → suspected_unmet,……); +- 只算复核过的问题(决策 E1);--include-unreviewed 时也算没复核的,报告里标明; +- 读标没抽到的问题单列「读标漏抽」:不算查标漏报,但计入端到端召回; +- 对不上任何标注单元的问题单列「无法对照」。 +输出:evals/out/check/<投标文件>/runs/<运行>/score.json、score.md。 +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys +from collections import Counter, defaultdict +from typing import Any + +EVALS = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EVALS)) +import gold # noqa: E402 + +OUT = EVALS / "out" / "check" +FAMILY = { + "D01": {"unresponded"}, + "D02": {"unresponded"}, + "D09": {"unresponded"}, + "D11": {"unresponded"}, + "D13": {"unresponded"}, + "D16": {"unresponded"}, + "D03": {"suspected_unmet"}, + "D07": {"suspected_unmet"}, + "D08": {"suspected_unmet"}, + "D05": {"negative_deviation", "suspected_unmet"}, + "D06": {"negative_deviation"}, + "D10": {"negative_deviation"}, + "D12": {"qualification"}, + "D14": {"qualification"}, + "D15": {"qualification", "unresponded"}, +} +FATAL_CATS = {"substantive", "qualification"} + + +def pct(a: int, b: int) -> str: + return f"{a}/{b}({a / b:.0%})" if b else "—" + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("bid") + ap.add_argument("--run", default="base") + ap.add_argument("--include-unreviewed", action="store_true") + a = ap.parse_args() + d = OUT / a.bid + manifest = json.loads((d / "manifest.json").read_text()) + res = json.loads((d / "runs" / a.run / "results.json").read_text()) + units, _ = gold.units(manifest["tender"]) + sid_units: dict[str, set[str]] = defaultdict(set) + for u in units: + for s in u.sids: + sid_units[s].add(u.id) + + per_unit: dict[str, list[dict[str, Any]]] = defaultdict(list) + unmapped: list[dict[str, Any]] = [] + for it in res["items"]: + hit = {u for s in it["sentences"] for u in sid_units.get(s, ())} + for u in hit: + per_unit[u].append(it) + if not hit and it["finding"]: + unmapped.append(it) + + rec: Counter[str] = Counter() + rec_total: Counter[str] = Counter() + sev_ok = sev_n = 0 + misses: list[str] = [] + extraction_miss: list[str] = [] + fp: list[str] = [] + fp_by_trap: Counter[str] = Counter() + ok_units = trap_units = 0 + other_findings = 0 + for uid, e in manifest["units"].items(): + items = per_unit.get(uid, []) + found = [it for it in items if it["finding"]] + if e["expect"] == "defect": + if not e.get("reviewed") and not a.include_unreviewed: + continue + keys = [e["defect"], f"粒度:{e['granularity']}", "带标志词" if e.get("worded") else "不带标志词"] + fatal = e["cat"] in FATAL_CATS + if fatal: + keys.append("致命级") + for k in keys: + rec_total[k] += 1 + rec_total["端到端"] += 1 + if not items: + extraction_miss.append(f"{uid}({e['defect']},{e['cat']})") + continue + rec_total["查标"] += 1 + good = [it for it in found if it["finding"]["type"] in FAMILY.get(e["defect"], set())] + if good: + for k in [*keys, "端到端", "查标"]: + rec[k] += 1 + if fatal: + sev_n += 1 + sev_ok += any(it["finding"]["severity"] == "fatal" for it in good) + else: + got = ", ".join(sorted({it["verdict"] for it in items})) + misses.append(f"{uid}({e['defect']} {e.get('variant', '')})→ 查标结论:{got}") + elif e["expect"] == "ok": + ok_units += 1 + if e.get("trap"): + trap_units += 1 + if found: + fp.append(f"{uid}({e['cat']},{e['mode']}):{found[0]['finding']['title'][:40]}") + if e.get("trap"): + fp_by_trap[e["trap"]] += 1 + elif found: + other_findings += 1 + + findings = [it for it in res["items"] if it["finding"]] + lines = [ + f"# 查标评分:{a.bid} · 运行 {a.run}", + "", + f"- 投标文件:{manifest['kind']},风格 {manifest['style']},{manifest['split']};清单 {len(res['items'])} 条,报出问题 {len(findings)} 个", + f"- 金标准:{'含未复核的问题(试跑口径)' if a.include_unreviewed else '只算复核过的问题'};复核方式见决策 E1", + f"- 模型:{res['summary']['usage']}", + "", + "## 召回", + "", + f"- 查标召回(读标抽到的要求上):{pct(rec['查标'], rec_total['查标'])}", + f"- 端到端召回(含读标漏抽):{pct(rec['端到端'], rec_total['端到端'])}", + f"- 致命级:{pct(rec['致命级'], rec_total['致命级'])};其中级别也判成致命的 {pct(sev_ok, sev_n)}", + "", + "| 分类 | 召回 |", + "|---|---|", + *[f"| {k} | {pct(rec[k], rec_total[k])} |" for k in sorted(rec_total) if k not in ("端到端", "查标", "致命级")], + "", + "## 误报", + "", + f"- 规矩响应的要求 {ok_units} 条,报了问题的 {len(fp)} 条(每 100 条 {100 * len(fp) / ok_units:.1f})" + if ok_units + else "- 没有规矩响应的要求", + f"- 干扰项 {trap_units} 条,误报 {dict(fp_by_trap)}", + f"- 要求本来不需要响应或无法判断(na / unknown)却报了问题:{other_findings}", + f"- 无法对照(对不上任何标注单元)的问题:{len(unmapped)}", + "", + f"## 漏报({len(misses)})", + "", + *[f"- {x}" for x in misses], + "", + f"## 读标漏抽({len(extraction_miss)})", + "", + *[f"- {x}" for x in extraction_miss], + "", + f"## 误报明细({len(fp)})", + "", + *[f"- {x}" for x in fp[:80]], + *(["- ……"] if len(fp) > 80 else []), + "", + f"## 规则问题(第 2 步才查,这里只列出):{len(manifest['rule_defects'])} 个", + "", + ] + out = d / "runs" / a.run + (out / "score.md").write_text("\n".join(lines)) + summary = { + "recall": {k: [rec[k], rec_total[k]] for k in rec_total}, + "fp": len(fp), + "ok_units": ok_units, + "fp_by_trap": dict(fp_by_trap), + "unmapped": len(unmapped), + "other_findings": other_findings, + "extraction_miss": len(extraction_miss), + "include_unreviewed": a.include_unreviewed, + } + (out / "score.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1)) + print("\n".join(lines[:20])) + + +if __name__ == "__main__": + main() diff --git a/evals/check/vocab-dev.json b/evals/check/vocab-dev.json new file mode 100644 index 0000000..80150cb --- /dev/null +++ b/evals/check/vocab-dev.json @@ -0,0 +1,21 @@ +{ + "_comment": "开发集用的注入词库(GUIDELINES §3.4)。留出集用另一份,由另一个会话写好封存在 evals/out/check/_sealed/vocab-holdout.json,开发时不打开。check.match 提示词的例子不能和这里的短语相同。", + "D05": {"phrases": ["暂不支持{param}", "{param}需二次开发后支持", "{param}为可选配置"]}, + "D06": {"deviations": ["负偏离", "部分满足", "偏离"]}, + "D08": {"phrases": ["满足", "完全响应", "符合要求"]}, + "D09": {"templates": ["{other}"]}, + "D10": {"cells": ["未完全响应", "部分响应", ""]}, + "P04": {"variants": ["drop_digit", "extra_shi", "decimal_shift"]}, + "P06": { + "foreign_projects": [ + {"name": "宿迁市人民医院智慧病房建设项目", "code": "JSZC-321300-HDZB-G2025-0318", "purchaser": "宿迁市人民医院"}, + {"name": "盐城市大数据中心容灾备份系统采购项目", "code": "JSZC-320900-YCZB-G2025-0742", "purchaser": "盐城市大数据管理局"}, + {"name": "泰州市第二中学智慧校园二期项目", "code": "JSZC-321200-TZJY-G2025-0096", "purchaser": "泰州市第二中学"} + ] + }, + "P07": {"blanks": ["__________", "年 月 日", "( )"]}, + "P08": {"dates": ["2026年12月28日", "2027年11月20日", "2026年13月02日"]}, + "P09": {"days": [60, 30, 45]}, + "P10": {"phrases": ["本报价不含税,税费另行计算。", "付款方式双方另行协商。", "最终数量以实际到货为准。"]}, + "P12": {"phrases": ["备选方案报价:{alt}元", "优惠报价:{alt}元", "方案二总价:{alt}元"]} +}