feat(evals): 留出集生成——风格 C、留出集改写导出;评测可模拟人工确认响应方式
- build.py / mutate.py:补风格 C(叙述型),由封存一侧的会话按封存说明实现;开发集风格 A / B 输出逐字节不变 - build.py:复述要求时去行首编号不再吃掉「2路服务器」这类内容数字 - export_rewrite.py:没有风格 A 稿时从未改写的风格 B 稿导出 - run_check.py:--modes 模拟清单页上人工确认的响应方式(C12 的人工确认点) - check-eval.md:第 3 步冻结点(a60d16e) 查标代码(server/src、prompts)自冻结后未改。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<ID>.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"]:
|
||||
|
||||
Reference in New Issue
Block a user