""" 标准投标技术标常见附件:按章节标题识别类型,生成带固定表头的 [TABLE]/[FIGURE] 块。 内容结合项目摘要、清单摘要做概括性填充(不照搬示例工程数据)。 """ from __future__ import annotations import re from datetime import date from typing import Any, Optional # 从标题中去掉行首编号(如 十二、附件一、) _TITLE_NUM_RE = re.compile( r'^[\s\u3000]*(?:[一二三四五六七八九十百零〇两]+|\d+)(?:[、..]\s*)+' ) def _strip_title_prefix(title: str) -> str: t = (title or '').strip() while True: m = _TITLE_NUM_RE.match(t) if not m: break t = t[m.end() :].strip() return t def classify_standard_appendix(title: str) -> Optional[str]: """ 识别常见附件类型。返回: main_equipment | test_instruments | labor_plan | schedule_chart | site_layout | temp_land """ t = _strip_title_prefix(title) if not t: return None # 主要施工设备表 if '主要施工设备' in t or ('拟投入' in t and '设备' in t and '表' in t): if '仪器' not in t and '检测' not in t: return 'main_equipment' # 试验和检测仪器设备表 if ('试验' in t or '检测仪器' in t or '检测' in t) and '设备' in t and '表' in t: return 'test_instruments' # 劳动力计划表 if '劳动力' in t and ('计划' in t or '表' in t): return 'labor_plan' # 临时用地表 if '临时用地' in t: return 'temp_land' # 进度网络图 / 横道图 / 开完工日期 if ('进度' in t or '开工' in t or '完工' in t) and ( '网络' in t or '横道' in t or '计划' in t or '日期' in t or '图' in t ): return 'schedule_chart' # 施工总平面图 if '施工总平面' in t or ('总平面' in t and '图' in t): return 'site_layout' return None def _ctx_hint(summary: str, boq: str, max_len: int = 400) -> str: parts = [] if (summary or '').strip(): parts.append((summary or '').strip()[:max_len]) if (boq or '').strip(): parts.append(('清单摘要:' + (boq or '').strip())[:max_len]) return ';'.join(parts) if parts else '按招标文件及施工组织要点配置' def _project_text(summary: str, boq: str) -> str: return f'{summary or ""}\n{boq or ""}' def _text_deterministic_seed(text: str) -> int: """短文本时仍能得到稳定偏移,便于同项目多次生成一致。""" raw = (text or '').strip()[:8000] if not raw: return 7 return sum(ord(c) * (i + 1) for i, c in enumerate(raw[:600])) % 997 def _equipment_scale_from_text(summary: str, boq: str) -> float: """设备台数规模系数,随项目信息量与关键词调整。""" t = _project_text(summary, boq) f = 1.0 if any(k in t for k in ('大规模', '重点', '总长', '线路', '隧道', '桥梁', '水利枢纽')): f += 0.1 if any(k in t for k in ('小型', '维修', '改造', '零星', '装修')): f -= 0.12 if len(t) > 3000: f += 0.06 elif len(t) < 350: f -= 0.06 return max(0.78, min(f, 1.22)) def _parse_area_sqm_near_keywords(text: str, keywords: tuple[str, ...]) -> Optional[int]: """在含关键词的行中抓取面积数字(㎡、平方米、m² 等)。""" if not text or not keywords: return None for line in text.splitlines(): if not any(k in line for k in keywords): continue m = re.search(r'(\d+(?:\.\d+)?)\s*(?:㎡|m2|m²|平方米)', line, re.I) if m: return max(1, int(round(float(m.group(1))))) m2 = re.search(r'(\d+(?:\.\d+)?)\s*平方', line) if m2: return max(1, int(round(float(m2.group(1))))) return None def _area_m2_with_context( text: str, keywords: tuple[str, ...], base_m2: int, scale: float, seed: int, row_index: int, lo: int, hi: int, ) -> int: hinted = _parse_area_sqm_near_keywords(text, keywords) if hinted is not None: return max(lo, min(hinted, hi)) v = int(round(base_m2 * scale)) v += ((seed >> (row_index * 4)) & 31) - 15 return max(lo, min(v, hi)) # 临时用地行定义(与临时用地表、施工总平面图共用) TEMP_LAND_SPECS: list[dict] = [ { 'use': '项目部办公', 'kws': ('项目部办公', '办公区', '项目部'), 'base': 150, 'lo': 80, 'hi': 420, 'loc': '施工用地内近大门侧、与作业区相对独立', 'time': '全施工期', }, { 'use': '门卫及保卫', 'kws': ('门卫', '保卫室', '门岗'), 'base': 18, 'lo': 10, 'hi': 55, 'loc': '施工现场主出入口内侧', 'time': '全施工期', }, { 'use': '工人生活区', 'kws': ('生活区', '宿舍区', '民工宿舍'), 'base': 220, 'lo': 120, 'hi': 650, 'loc': '场区一侧,与加工堆场保持安全距离', 'time': '全施工期', }, { 'use': '食堂与卫浴', 'kws': ('食堂', '伙房', '卫浴', '卫生间'), 'base': 42, 'lo': 25, 'hi': 120, 'loc': '生活区内上风向、排水接入沉淀设施', 'time': '全施工期', }, { 'use': '材料堆场', 'kws': ('堆场', '材料堆场', '仓储'), 'base': 320, 'lo': 150, 'hi': 900, 'loc': '塔吊覆盖范围内、靠近施工道路', 'time': '分阶段布设,与进度同步', }, { 'use': '钢木加工车间', 'kws': ('加工区', '加工车间', '钢筋加工', '木工棚'), 'base': 300, 'lo': 160, 'hi': 800, 'loc': '靠近堆场与环场道路,设防噪声围挡', 'time': '主体施工高峰期为主', }, { 'use': '机械停放区', 'kws': ('机械停放', '设备停放', '停车场'), 'base': 200, 'lo': 100, 'hi': 550, 'loc': '硬化地坪、靠近出入口便于进退场', 'time': '全施工期', }, { 'use': '临时道路及硬化场地', 'kws': ('临时道路', '施工道路', '硬化'), 'base': 980, 'lo': 400, 'hi': 2800, 'loc': '环场主干道及作业面连通支线', 'time': '随施工阶段动态调整', }, ] def compute_temp_land_rows(summary: str, boq: str) -> list[dict[str, Any]]: """与临时用地表一致的用途、面积(㎡)、位置、时间(供总平面图文字勾连)。""" ctx = _project_text(summary, boq) seed = _text_deterministic_seed(ctx) scale = _equipment_scale_from_text(summary, boq) out: list[dict[str, Any]] = [] for i, spec in enumerate(TEMP_LAND_SPECS): m2 = _area_m2_with_context( ctx, spec['kws'], spec['base'], scale, seed, i, spec['lo'], spec['hi'], ) out.append( { 'use': spec['use'], 'area_m2': m2, 'loc': spec['loc'], 'time': spec['time'], } ) return out def _build_temp_land_table_md(title: str, summary: str, boq: str) -> str: """ 临时用地表:面积(㎡) 给出具体整数;优先从摘要/清单中解析与用途相关的面积表述。 """ clean = (title or '临时用地表').strip() hint = _ctx_hint(summary, boq) rows = compute_temp_land_rows(summary, boq) header = '| 用途 | 面积(㎡) | 位置 | 需用时间 |' sep = '|------|----------|------|----------|' lines = [header, sep] for r in rows: lines.append( '| {use} | {m2} | {loc} | {time} |'.format( use=r['use'], m2=r['area_m2'], loc=r['loc'], time=r['time'], ) ) body = '\n'.join(lines) return ( f'[TABLE:{clean}]\n' f'{body}\n' f'[/TABLE]\n' f'(各项面积已按项目规模具体填写;若总平面布置或招标文件另有用地指标,以{hint}及审批总平面为准复核。)' ) def _parse_qty_near_keywords(text: str, keywords: tuple[str, ...]) -> Optional[int]: """在含关键词的行中抓取「数字+台/套/辆」。""" if not text or not keywords: return None for line in text.splitlines(): if not any(k in line for k in keywords): continue for m in re.finditer(r'(\d+)\s*(?:台|套|辆|组|把)', line): n = int(m.group(1)) if 1 <= n <= 999: return n return None def _parse_year_from_text(text: str) -> Optional[int]: """摘要/清单中出现的合理制造年份(近年出厂设备)。""" years = [int(y) for y in re.findall(r'(20[1-2]\d)', text or '')] if not years: return None y = max(years) if 2015 <= y <= 2026: return y return None def _pick_year(base_year: Optional[int], row_index: int, seed: int) -> int: """制造年份:优先项目文本;否则在近年区间内按行略作错落。""" if base_year is not None: return max(2018, min(base_year, 2025)) pool = [2019, 2020, 2021, 2022, 2023, 2024] return pool[(seed + row_index * 17) % len(pool)] def _qty_with_context( text: str, keywords: tuple[str, ...], base_qty: int, scale: float, seed: int, row_index: int, ) -> int: hinted = _parse_qty_near_keywords(text, keywords) if hinted is not None: return max(1, min(hinted, 99)) q = int(round(base_qty * scale)) jitter = ((seed >> (row_index * 3)) & 3) - 1 q = max(1, q + jitter) return min(q, 99) # 主要施工设备行定义(设备表与总平面图「允许出现的机械」共用关键词) MAIN_EQUIPMENT_SPECS: list[dict] = [ { 'name': '液压挖掘机', 'kws': ('挖掘机', '液压挖', '反铲', '土方机械'), 'q0': 4, 'models': ('SY235C-10', 'PC220-8MO', 'ZX240LC-5A'), 'powers': (125, 132, 128), 'cap': ('斗容约1.1m³', '斗容约1.0m³', '斗容约1.2m³'), 'part': '土方开挖、基坑与回填作业面', 'rmk': '自有或租赁按进度调配', }, { 'name': '履带式推土机', 'kws': ('推土机', '平整'), 'q0': 2, 'models': ('SD16标准型', 'D6K LGP', 'TY160'), 'powers': (120, 104, 121), 'cap': ('额定功率匹配铲刀作业', '铲刀容量与土质匹配', '平整效率满足工序'), 'part': '场地平整、路基粗平', 'rmk': '与挖装设备流水衔接', }, { 'name': '自卸汽车', 'kws': ('自卸', '渣土', '运输', '汽车'), 'q0': 10, 'models': ('东风天锦 15t', '重汽豪沃 18t', '解放J6P 20t'), 'powers': (180, 228, 265), 'cap': ('额定载重15t', '额定载重18t', '额定载重20t'), 'part': '土方、砂石料及混凝土运输', 'rmk': '道路与地磅条件满足通行', }, { 'name': '轮式装载机', 'kws': ('装载机', '铲车'), 'q0': 3, 'models': ('ZL50GN', 'L956F', 'WA380-6'), 'powers': (162, 175, 142), 'cap': ('斗容约3.0m³', '斗容约2.8m³', '斗容约2.5m³'), 'part': '堆场装车、短驳与备料', 'rmk': '与运输车辆匹配', }, { 'name': '振动压路机', 'kws': ('压路机', '碾压', '压实'), 'q0': 3, 'models': ('XS223J', 'CA30D', 'SR26M-C5'), 'powers': (118, 132, 129), 'cap': ('工作质量约22t', '激振力与遍数受控', '适用于基层与面层'), 'part': '路基、基层及面层压实', 'rmk': '试验段确定压实参数', }, { 'name': '混凝土搅拌运输车', 'kws': ('搅拌车', '罐车', '商砼'), 'q0': 6, 'models': ('12m³ 陕汽底盘', '10m³ 三一重工', '12m³ 中联重科'), 'powers': (276, 257, 288), 'cap': ('几何容积12m³', '搅动与卸料满足泵送', 'GPS调度'), 'part': '商品混凝土水平运输', 'rmk': '与泵车浇筑节拍匹配', }, { 'name': '汽车式起重机', 'kws': ('汽车吊', '起重机', '吊装'), 'q0': 2, 'models': ('QY25K5C 25t', 'STC250T4 25t', 'XCT25L5'), 'powers': (213, 206, 210), 'cap': ('最大起重量25t', '主臂长度满足构件', '支腿全伸作业'), 'part': '钢筋、模板及小型构件吊装', 'rmk': '专项方案与地基承载复核', }, { 'name': '柴油发电机组', 'kws': ('发电机', '发电', '备用电源'), 'q0': 2, 'models': ('GF-200kW', 'GF-250kW', 'SC9D340D2'), 'powers': (200, 250, 308), 'cap': ('连续输出功率200kW', '连续输出功率250kW', '连续输出功率280kW'), 'part': '基坑降水、塔吊及高峰施工备用电', 'rmk': '一机一闸一漏一箱', }, ] def _allowed_equipment_names(summary: str, boq: str, limit: int = 12) -> list[str]: """ 总平面图允许绘制的机械:摘要/清单中出现与设备关键词匹配的机型名称; 不臆造未在资料中出现的设备类别。 """ ctx = _project_text(summary, boq) names: list[str] = [] for spec in MAIN_EQUIPMENT_SPECS: if any(k in ctx for k in spec['kws']): names.append(spec['name']) # 清单中直接出现的机械用语(补充) extra_tokens = ( ('塔吊', '塔式起重机'), ('塔机', '塔式起重机'), ('施工电梯', '施工升降机'), ('泵车', '混凝土泵车'), ('车载泵', '混凝土泵车'), ('静压桩机', '静力压桩机'), ('旋挖钻', '旋挖钻机'), ('摊铺机', '沥青摊铺机'), ('铣刨机', '路面铣刨机'), ) for tok, label in extra_tokens: if tok in ctx and label not in names: names.append(label) seen: set[str] = set() out: list[str] = [] for n in names: if n not in seen: seen.add(n) out.append(n) if len(out) >= limit: break return out def _build_main_equipment_table_md(title: str, summary: str, boq: str) -> str: """ 主要施工设备表:型号规格、数量、制造年份、定额功率等尽量给出具体数值; 优先从项目摘要/清单中解析台数线索,其余按工程规模系数与稳定随机偏移生成合理配置。 """ clean = (title or '主要施工设备表').strip() hint = _ctx_hint(summary, boq) ctx = _project_text(summary, boq) seed = _text_deterministic_seed(ctx) scale = _equipment_scale_from_text(summary, boq) year_hint = _parse_year_from_text(ctx) specs = MAIN_EQUIPMENT_SPECS header = ( '| 序号 | 机械或设备名称 | 型号规格 | 数量 | 国别产地 | 制造年份 | ' '定额功率(KW) | 生产能力 | 用于施工部位 | 备注 |' ) sep = ( '|------|----------------|----------|------|----------|----------|' '---------------|----------|--------------|------|' ) lines = [header, sep] for i, spec in enumerate(specs): mi = (seed + i * 31) % len(spec['models']) model = spec['models'][mi] power = spec['powers'][mi] cap = spec['cap'][mi] qty = _qty_with_context(ctx, spec['kws'], spec['q0'], scale, seed, i) qty_str = f'{qty}台' if spec['name'] in ('自卸汽车', '混凝土搅拌运输车'): qty_str = f'{qty}辆' elif spec['name'] in ('柴油发电机组',): qty_str = f'{qty}套' year = _pick_year(year_hint, i, seed) pwr_str = str(power) lines.append( '| {idx} | {name} | {model} | {qtys} | 中国 | {year} | {pwr} | {cap} | {part} | {rmk} |'.format( idx=i + 1, name=spec['name'], model=model, qtys=qty_str, year=year, pwr=pwr_str, cap=cap, part=spec['part'], rmk=spec['rmk'], ) ) body = '\n'.join(lines) return ( f'[TABLE:{clean}]\n' f'{body}\n' f'[/TABLE]\n' f'(型号、台数、功率等已按项目资料做具体化填写;若清单或技术条款另有约定,以{hint}为准最终核定。)' ) def _instrument_used_hours( year: int, seed: int, row_index: int, powered: bool, ) -> str: """ 已使用台时数:机动/电子类给具体整数(台时);非动力器具为 0。 随出厂年份与稳定种子变化,避免全表雷同。 """ if not powered: return '0' ynow = date.today().year age = max(0, min(ynow - year, 8)) base = 380 + (seed % 220) + row_index * 88 annual = 260 + ((seed >> (row_index + 2)) & 0xFF) % 240 h = int(base + age * annual) h = max(160, min(h, 5600)) return str(h) def _instrument_qty( text: str, keywords: tuple[str, ...], base_qty: int, scale: float, seed: int, row_index: int, max_qty: int = 12, ) -> int: hinted = _parse_qty_near_keywords(text, keywords) if hinted is not None: return max(1, min(hinted, max_qty)) q = int(round(base_qty * scale)) jitter = ((seed >> (row_index * 4)) & 3) - 1 q = max(1, q + jitter) return min(q, max_qty) def _build_test_instruments_table_md(title: str, summary: str, boq: str) -> str: """ 试验和检测仪器设备表:型号规格、数量、制造年份、已使用台时数为具体数值; 台数优先从摘要/清单中含关键词的行解析。 """ clean = (title or '试验和检测仪器设备表').strip() hint = _ctx_hint(summary, boq) ctx = _project_text(summary, boq) seed = _text_deterministic_seed(ctx) scale = _equipment_scale_from_text(summary, boq) year_hint = _parse_year_from_text(ctx) rows_spec: list[dict] = [ { 'name': '全站仪', 'kws': ('全站仪', '全站', '测量仪'), 'q0': 2, 'models': ('徕卡 TS09plus 1″', '南方 NTS-362R10', '苏一光 RTS112R10'), 'part': '施工控制测量、轴线与坐标放样', 'rmk': '检定合格且在有效期内', 'powered': True, 'unit': '台', }, { 'name': '水准仪', 'kws': ('水准仪', '水准'), 'q0': 3, 'models': ('苏一光 DSZ2', '徕卡 NA730', '天津赛博 DS32'), 'part': '高程传递、沉降与水准路线观测', 'rmk': 'i角定期校验', 'powered': True, 'unit': '台', }, { 'name': '钢卷尺', 'kws': ('钢卷尺', '卷尺'), 'q0': 8, 'models': ('5m Ⅰ级', '50m 标准钢卷尺', '30m Ⅰ级'), 'part': '距离量测、模板与预埋位置复核', 'rmk': '周期检定', 'powered': False, 'unit': '把', }, { 'name': '游标卡尺', 'kws': ('游标卡尺', '卡尺'), 'q0': 6, 'models': ('0–150mm 0.02mm', '0–200mm 0.02mm', '数显卡尺 0–150mm'), 'part': '钢筋直径、螺栓与加工件尺寸抽检', 'rmk': '计量台账管理', 'powered': False, 'unit': '把', }, { 'name': '混凝土回弹仪', 'kws': ('回弹仪', '回弹'), 'q0': 2, 'models': ('ZC3-A 中回', 'HT-225A', '瑞士 Proceq Original Schmidt'), 'part': '结构混凝土强度现场抽检', 'rmk': '率定试验按期进行', 'powered': True, 'unit': '台', }, { 'name': '数字万用表', 'kws': ('万用表',), 'q0': 4, 'models': ('Fluke 117', '优利德 UT39C+', '胜利 VC890C+'), 'part': '临电线路、设备绝缘与电压电流测试', 'rmk': 'CAT III 安全等级满足现场', 'powered': True, 'unit': '台', }, { 'name': '接地电阻测试仪', 'kws': ('接地电阻', '接地测试'), 'q0': 2, 'models': ('ETCR2000A', 'Fluke 1623-2', '胜利 VC4105A'), 'part': '接地网、防雷及设备接地电阻测试', 'rmk': '三极法/钳形法按规范选用', 'powered': True, 'unit': '台', }, { 'name': '绝缘电阻测试仪', 'kws': ('绝缘电阻', '兆欧表'), 'q0': 2, 'models': ('Fluke 1507 1000V', '优利德 UT501A', '胜利 VC60B+'), 'part': '电缆、电机及配电回路绝缘测试', 'rmk': '送电前必测项目', 'powered': True, 'unit': '台', }, { 'name': '电液伺服压力试验机', 'kws': ('压力试验机', '试验机', '万能试验机'), 'q0': 1, 'models': ('YAW-3000 微机控制', 'WAW-1000 微机控制', 'YES-2000 数显'), 'part': '混凝土、砂浆试块抗压强度试验', 'rmk': '与标养室及见证取样制度配套', 'powered': True, 'unit': '台', }, { 'name': '超声波测厚仪', 'kws': ('测厚仪', '超声波测厚'), 'q0': 2, 'models': ('TT130 0.1mm', 'Olympus 38DL PLUS', '时代 TIME2130'), 'part': '钢管、钢板及防腐层厚度抽检', 'rmk': '耦合剂与探头匹配管材材质', 'powered': True, 'unit': '台', }, ] header = ( '| 序号 | 仪器设备名称 | 型号规格 | 数量 | 国别产地 | 制造年份 | ' '已使用台时数 | 用于施工部位 | 备注 |' ) sep = ( '|------|--------------|----------|------|----------|----------|' '--------------|--------------|------|' ) lines = [header, sep] for i, spec in enumerate(rows_spec): mi = (seed + i * 29) % len(spec['models']) model = spec['models'][mi] qty_n = _instrument_qty(ctx, spec['kws'], spec['q0'], scale, seed, i, max_qty=15) qty_str = f'{qty_n}{spec["unit"]}' year = _pick_year(year_hint, i, seed) hours = _instrument_used_hours(year, seed, i, spec['powered']) rmk = spec['rmk'] lines.append( '| {idx} | {name} | {model} | {qty} | 中国 | {year} | {hours} | {part} | {rmk} |'.format( idx=i + 1, name=spec['name'], model=model, qty=qty_str, year=year, hours=hours, part=spec['part'], rmk=rmk, ) ) body = '\n'.join(lines) return ( f'[TABLE:{clean}]\n' f'{body}\n' f'[/TABLE]\n' f'(型号、数量、出厂年份、已使用台时数已按项目资料具体填写;机动器具台时为累计估值,非机动为0;最终以{hint}及设备台账、检定证书为准。)' ) def _labor_peak_factor(summary: str, boq: str) -> float: """根据摘要/清单篇幅与关键词略调人数规模,避免所有项目同一套数字。""" text = (summary or '') + (boq or '') f = 1.0 if any(k in text for k in ('大规模', '重点工程', '总建筑面积', '线路长度', '合同额')): f += 0.12 if any(k in text for k in ('小型', '维修', '改造', '零星')): f -= 0.08 n = len(text) if n > 2500: f += 0.08 elif n < 400: f -= 0.05 return max(0.82, min(f, 1.28)) # 各阶段人数基准(人):准备 / 建筑主体 / 临时工程 / 附属 / 收尾;峰值落在主体阶段 _LABOR_BASE_ROWS: list[tuple[str, tuple[int, int, int, int, int]]] = [ ('测量工', (4, 8, 4, 6, 3)), ('挖掘机司机', (2, 8, 6, 2, 1)), ('装载机司机', (1, 5, 4, 2, 1)), ('自卸车司机', (2, 10, 6, 4, 2)), ('木工', (0, 8, 2, 4, 2)), ('砼工', (0, 20, 4, 8, 6)), ('钢筋工', (0, 18, 4, 8, 4)), ('电工', (3, 4, 3, 4, 3)), ('普工', (10, 30, 20, 20, 10)), ('试验工', (2, 4, 2, 3, 2)), ('仓管', (2, 3, 2, 3, 2)), ] def _build_labor_plan_table_md(title: str, summary: str, boq: str) -> str: clean = (title or '劳动力计划表').strip() hint = _ctx_hint(summary, boq) factor = _labor_peak_factor(summary, boq) # 表头五列名称须与「按工程施工阶段投入劳动力情况」下子列一致(导出 Word 时做双层合并表头) header = ( '| 工种 | 施工准备阶段 | 建筑工程施工阶段 | 临时工程施工阶段 | ' '其他附属相关工程 | 收尾阶段 |' ) sep = '|------|--------------|------------------|------------------|------------------|----------|' lines = [header, sep] scaled_rows: list[tuple[int, int, int, int, int]] = [] for trade, nums in _LABOR_BASE_ROWS: row = tuple(max(0, int(round(n * factor))) for n in nums) scaled_rows.append(row) a, b, c, d, e = row lines.append(f'| {trade} | {a} | {b} | {c} | {d} | {e} |') if scaled_rows: tot = tuple(sum(r[j] for r in scaled_rows) for j in range(5)) lines.append( f'| 合计 | {tot[0]} | {tot[1]} | {tot[2]} | {tot[3]} | {tot[4]} |' ) body = '\n'.join(lines) return ( f'[TABLE:{clean}]\n' f'{body}\n' f'[/TABLE]\n' f'(上表为按施工阶段配置的**人数**估算,单位:人;高峰一般集中在建筑工程施工阶段,须结合工期与作业面以{hint}为准复核调整。)' ) def _find_date_after_keyword(text: str, keywords: tuple[str, ...], window: int = 56) -> Optional[str]: """在关键词后若干字符内抓取 yyyy年mm月dd日 或 yyyy-mm-dd。""" if not text: return None for kw in keywords: start = 0 while True: idx = text.find(kw, start) if idx < 0: break frag = text[idx : idx + len(kw) + window] dm = re.search(r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日', frag) if dm: return f'{dm.group(1)}年{dm.group(2)}月{dm.group(3)}日' dm2 = re.search(r'(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})', frag) if dm2: return f'{dm2.group(1)}年{dm2.group(2)}月{dm2.group(3)}日' start = idx + len(kw) return None def _parse_duration_calendar_days(text: str) -> Optional[int]: if not text: return None for pat in ( r'(\d+)\s*日历天', r'总工期[::为]?\s*(\d+)\s*天', r'工期[((]?\s*含?[^))]*?[))]?\s*[::为]?\s*(\d+)\s*天', r'计划工期[::为]?\s*(\d+)\s*天', ): m = re.search(pat, text) if m: n = int(m.group(1)) if 1 <= n <= 3650: return n return None def _parse_schedule_facts(summary: str, boq: str) -> dict[str, Any]: """从摘要+清单摘取开工、完工、日历天及里程碑行(摘不到则为空,禁止下游杜撰)。""" ctx = _project_text(summary, boq) start = _find_date_after_keyword( ctx, ('计划开工', '开工日期', '拟定开工', '合同开工', '开工时间', '开工日'), ) end = _find_date_after_keyword( ctx, ('计划完工', '完工日期', '竣工日期', '交工日期', '合同完工', '竣工时间', '完工时间'), ) duration = _parse_duration_calendar_days(ctx) milestones: list[str] = [] for line in ctx.splitlines(): s = line.strip() if any( k in s for k in ( '里程碑', '节点工期', '控制性节点', '关键节点', '主体封顶', '竣工验收', '中间验收', ) ): if 8 < len(s) < 160: milestones.append(s) if len(milestones) >= 8: break return { 'start': start, 'end': end, 'duration_days': duration, 'milestones': milestones, } def _boq_line_cells(line: str) -> list[str]: line = line.strip() if line.startswith('|') and line.endswith('|'): return [c.strip() for c in line[1:-1].split('|')] return [] def _extract_boq_work_items(summary: str, boq: str, max_items: int = 14) -> list[str]: """ 从工程量清单摘要/表格行中抽取可作为进度网络节点的分项名称。 仅使用文本中已出现的短语,不编造清单外工程。 """ primary = (boq or '').strip() or (summary or '').strip() secondary = (summary or '').strip() if primary != (summary or '').strip() else '' candidates: list[str] = [] def _consume(text: str) -> None: for raw in text.splitlines(): line = raw.strip() if not line or len(line) < 4: continue if re.match(r'^\|[\s\-:|]+\|$', line): continue if line.startswith('|'): cells = _boq_line_cells(line) for c in cells: c = c.replace('**', '').strip() if 4 <= len(c) <= 80 and re.search(r'[\u4e00-\u9fff]', c): if re.match(r'^(序号|编码|项目编码|名称|单位|工程量|单价|合价|分部|分项)$', c): continue if re.match(r'^[\d\.%%]+$|^第?[一二三四五六七八九十]+[章节部分项、]', c): continue candidates.append(c) continue m = re.match(r'^[\d\.]+\s+(.+)', line) if m: frag = m.group(1).strip()[:80] if re.search(r'[\u4e00-\u9fff]', frag): candidates.append(frag) continue if re.match(r'^[((]?[一二三四五六七八九十百零〇\d]+[))]\s*[、..]?\s*.+', line) and len(line) < 100: candidates.append(line[:80]) _consume(primary) if secondary: _consume(secondary) seen: set[str] = set() out: list[str] = [] for c in candidates: key = c.strip() if key and key not in seen: seen.add(key) out.append(key) if len(out) >= max_items: break return out def _schedule_figure_body( clean: str, hint: str, sch: dict[str, Any], nodes: list[str], ) -> str: start_s = sch.get('start') end_s = sch.get('end') nd = sch.get('duration_days') milestones = sch.get('milestones') or [] lines: list[str] = [ f'[FIGURE:{clean}]', '【编制原则】进度网络/横道须与招标文件计划工期及工程量清单工作范围一致;下列日期、日历天与节点均**仅摘自**已解析的项目摘要与清单摘要,摘要未出现的**不得臆造**。', '【计划工期(摘自资料)】', ] if start_s: lines.append(f'· 计划开工日期(摘录):{start_s}') else: lines.append('· 计划开工日期:摘要/清单未摘录到明确日期的,以招标文件、补遗书及合同协议书为准,**本图不填写具体开工日**。') if end_s: lines.append(f'· 计划完工/竣工日期(摘录):{end_s}') else: lines.append('· 计划完工/竣工日期:同上,以招标文件明示为准,**本图不填写具体完工日**。') if nd: lines.append(f'· 总工期(摘录):{nd} 日历天') else: lines.append('· 总工期:摘要未摘录日历天数的,**不绘制具体日历刻度**,仅表示工序逻辑先后。') if milestones: lines.append('【控制性节点/里程碑(摘自资料原文)】') for m in milestones[:6]: lines.append(f'· {m}') else: lines.append('【控制性节点】资料未摘录专项里程碑条目的,不强行列举;实施阶段按监理与招标人批复计划执行。') lines.append('【工程量清单工作项与网络节点(图中工序名称仅限下列,禁止增加清单外内容)】') if nodes: for i, n in enumerate(nodes, 1): lines.append(f'{i}. {n}') else: lines.append( '(清单摘要未解析到分项名称时:网络图仅表示「施工准备 → 清单列项施工 → 验收移交」三阶段逻辑关系,' '具体分项以招标工程量清单及图纸为准。)' ) lines.append('【施工进度逻辑示意】') if nodes: chain = ' → '.join(nodes[:10]) lines.append(f'逻辑关系(示意):开始 → {chain} → 结束') else: lines.append('逻辑关系(示意):开始 → 施工准备 → 清单工程施工 → 竣工验收与移交 → 结束') ndays = sch.get('duration_days') if ndays and isinstance(ndays, int) and nodes: lines.append( f'横道比例说明:总控制工期 {ndays} 日历天(摘自资料);下列横道仅为相对比例示意,条形长度不代表未在招标文件中给出的细部作业日历。' ) width = 36 n_show = min(len(nodes), 8) for i, node in enumerate(nodes[:n_show]): frac = (i + 1) / (n_show + 1) filled = max(1, min(width, int(round(width * frac)))) bar = '█' * filled + '░' * (width - filled) label = node[:18] + ('…' if len(node) > 18 else '') lines.append(f'{label:<20} {bar}') elif nodes: lines.append('(未摘录总日历天:横道仅表示先后顺序,不标注天数刻度。)') for i, node in enumerate(nodes[:10], 1): lines.append(f' {i}. {node}') lines.append(f'(编制依据摘要:{hint})') lines.append('[/FIGURE]') return '\n'.join(lines) def _build_schedule_figure_md(title: str, summary: str, boq: str) -> str: clean = (title or '施工进度计划网络图').strip() hint = _ctx_hint(summary, boq) sch = _parse_schedule_facts(summary, boq) nodes = _extract_boq_work_items(summary, boq) return _schedule_figure_body(clean, hint, sch, nodes) def _site_layout_ascii(zones: list[str]) -> str: """极简 ASCII,仅标注已给出的用地用途名称。""" if not zones: return '(临时用地分区见临时用地表)' z = zones[:8] top = ' — '.join(f'[{a}]' for a in z[:4]) bot = '' if len(z) > 4: bot = ' — '.join(f'[{a}]' for a in z[4:]) parts = [top] if bot: parts.append(' │') parts.append(' ' + bot) return '\n'.join(parts) def _build_site_layout_figure_md(title: str, summary: str, boq: str) -> str: clean = (title or '施工总平面图').strip() hint = _ctx_hint(summary, boq) land_rows = compute_temp_land_rows(summary, boq) equip = _allowed_equipment_names(summary, boq) lines: list[str] = [ f'[FIGURE:{clean}]', '【编制原则】总平面示意须与《临时用地表》分区及面积一致;图中出现的施工机械**仅限**下列清单,禁止绘制与项目资料无关的塔吊、挖掘机等设备剪影。', '【临时用地分区与面积(与临时用地表同口径)】', ] for r in land_rows: lines.append(f'· {r["use"]}:{r["area_m2"]}㎡;位置:{r["loc"]};时间:{r["time"]}') lines.append('【允许绘制的施工机械(仅下列;与摘要/清单及拟投入设备语义匹配)】') if equip: for e in equip: lines.append(f'· {e}') else: lines.append( '· 资料未检出明确机械类别:总平面图中**不绘制具体机械外形**,仅保留「机械停放区」块状示意,设备以《主要施工设备表》及招标清单最终表述为准。' ) zone_labels = [str(r['use']) for r in land_rows] lines.append('【相对位置关系示意(非比例尺;分区名称与上表一致)】') lines.append(_site_layout_ascii(zone_labels)) lines.append( '文字要点:环场道路、门禁、办公生活区与作业区隔离、堆场与加工区相对位置、排水沉淀、消防设施布置等须符合招标文件及用地表;' f'具体坐标以审批总平面为准。(依据摘要:{hint})' ) lines.append('[/FIGURE]') return '\n'.join(lines) def build_standard_appendix_markdown( kind: str, title: str, summary: str = '', boq_summary: str = '', ) -> str: """生成完整单块 markdown(含 [TABLE] 或 [FIGURE])。""" clean = (title or '附件').strip() hint = _ctx_hint(summary, boq_summary) if kind == 'main_equipment': return _build_main_equipment_table_md(clean, summary, boq_summary) if kind == 'test_instruments': return _build_test_instruments_table_md(clean, summary, boq_summary) if kind == 'labor_plan': return _build_labor_plan_table_md(clean, summary, boq_summary) if kind == 'temp_land': return _build_temp_land_table_md(clean, summary, boq_summary) if kind == 'schedule_chart': return _build_schedule_figure_md(clean, summary, boq_summary) if kind == 'site_layout': return _build_site_layout_figure_md(clean, summary, boq_summary) return '' def is_mandatory_bid_appendix(title: str) -> bool: """是否为应强制输出图/表块的标准附件(与「正文可选图表」开关解耦)。""" return classify_standard_appendix(title) is not None