Files
bid-doctor/evals/check/build.py
YANG JIANKUAN b2b8584321 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>
2026-11-04 18:58:52 +08:00

716 lines
32 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""从「招标文件档案」和读标清单生成规矩投标文件的结构化稿 spec.json(evals/check/GUIDELINES.md §1、§5)。
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)
{
"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日",
}
NUMS = re.compile(r"\d+(?:\.\d+)?")
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 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)}
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"],
"key": r.get("key"),
"tp": r["tp"],
}
# 复述要求时去掉开头的编号和标记(「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", {})
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 = "满足," + LEADING.sub("", req_text)[:120], "无偏离"
rw = dict(rewrite.get(iid) or {})
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(
{
"iid": iid,
"key": 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)}
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
text = "".join(x.text for x in sents)
rw = rewrite.get(f"u:{u.id}") or {}
rows_by_route.setdefault("tech_row", []).append(
{
"iid": None,
"key": f"u:{u.id}",
"requirement": rw.get("requirement", text),
"answer": rw.get("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)]
moved: list[dict[str, Any]] = [] # 风格 B:一部分技术行写在商务资格册里(干扰项 T01)
moved_columns: list[str] = []
for n, f in enumerate(profile["forms"], start=1):
start = len(blocks)
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"
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 ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"]
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("deviation") if "deviation" in columns else 0,
}
]
pool = [
_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 = []
if style == "B" and f.get("route", "tech_row") == "tech_row" and not f.get("mode"):
keep = []
for k, r in enumerate(rows):
(moved if k % 12 == 11 else keep).append(r)
rows, moved_columns = keep, columns
if style == "B" and "no" in columns: # 风格 B 用自己的编号
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,
{
"type": "table",
"header": header,
"rows": rows,
"form": f["key"],
"route": f.get("route", "tech_row"),
"only_deviations": bool(f.get("only_deviations")),
"mode": f.get("mode"),
"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}")
tech = kind == "response_table" and f.get("route", "tech_row") == "tech_row"
for b in blocks[start:]:
b["vol"] = "v2" if style == "B" and tech else "v1"
if moved:
for k, r in enumerate(moved, 1):
if "no" in moved_columns:
r["cells"][moved_columns.index("no")] = f"补-{k:02d}"
blocks += [
{"type": "heading", "text": "补充技术响应", "new_page": True, "vol": "v1"},
{
"type": "table",
"header": moved_columns,
"rows": moved,
"form": "moved",
"route": "tech_row",
"only_deviations": False,
"mode": None,
"columns": moved_columns,
"pool": [],
"vol": "v1",
},
]
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 += [
{"type": "para", "text": fill(p, ctx), "responds": units if i == 0 else [], "form": "prose"}
for i, p in enumerate(sec["paragraphs"])
]
for b in blocks[-len(sec["paragraphs"]) - 1 :]:
b["vol"] = "v2" if style == "B" else "v1"
blocks[0]["vol"] = "v1"
if style == "B":
volumes = [
{"id": "v1", "kind": "commercial", "header": None, "blocks": [b for b in blocks if b.get("vol") == "v1"]},
{
"id": "v2",
"kind": "technical",
"header": None,
"blocks": [
{"type": "title", "text": f"{proj.get('name', '')}投标文件(技术册)"},
*[b for b in blocks if b.get("vol") == "v2"],
],
},
]
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"]:
traps.setdefault(u, "T03" if "answer" in rewrite[v] and "requirement" not in rewrite[v] else "T02")
spec = {
"bid": bid,
"tender": tender,
"style": style,
"project_name": proj.get("name", ""),
"bidder": BIDDER,
"volumes": volumes,
"traps": traps,
"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)