- 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>
716 lines
32 KiB
Python
716 lines
32 KiB
Python
"""从「招标文件档案」和读标清单生成规矩投标文件的结构化稿 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)
|