290 lines
14 KiB
Python
290 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
import os
|
||
import re
|
||
import time
|
||
from pathlib import Path
|
||
from typing import List, Dict
|
||
from docx import Document
|
||
from openai import AsyncOpenAI
|
||
import asyncio
|
||
from datetime import datetime
|
||
|
||
from config import VLLM_BASE_URL, VLLM_MODEL_NAME
|
||
|
||
# ==================== 配置区 ====================
|
||
INPUT_WORD = r"C:\Users\YC\Desktop\1.docx"
|
||
OUTPUT_WORD = r"C:\Users\YC\Desktop\投标文件-最终版.docx"
|
||
|
||
|
||
# 统一中间文件目录(强烈建议不要改)
|
||
PROCESS_DIR = "12并发过程文件"
|
||
# ===============================================
|
||
|
||
# ==================== 12并发 ===================
|
||
client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="EMPTY", timeout=1800)
|
||
SEM = asyncio.Semaphore(12)
|
||
|
||
# ==================== 进度条 ====================
|
||
class ProgressTracker:
|
||
def __init__(self, total: int, name: str):
|
||
self.total = total
|
||
self.name = name
|
||
self.completed = 0
|
||
self.start = time.time()
|
||
|
||
def update(self):
|
||
self.completed += 1
|
||
elapsed = time.time() - self.start
|
||
avg = elapsed / self.completed
|
||
eta = (self.total - self.completed) * avg
|
||
bar = "█" * (30 * self.completed // self.total) + "░" * (30 - 30 * self.completed // self.total)
|
||
print(f"\r{self.name} |{bar}| {self.completed}/{self.total} "
|
||
f"[{100*self.completed/self.total:5.1f}%] "
|
||
f"已用 {elapsed/60:4.1f}分 预计剩余 {eta/60:4.1f}分", end="")
|
||
|
||
def finish(self):
|
||
total_time = time.time() - self.start
|
||
print(f"\n√ {self.name} 完成!总耗时 {total_time/60:.2f} 分钟")
|
||
|
||
TOTAL_START = time.time()
|
||
print("="*90)
|
||
print("12并发生成投标文件 · 字典+回调法(终极优化版)")
|
||
print(f"启动时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||
print("="*90)
|
||
|
||
# ==================== 异步调用 ====================
|
||
async def call_llm_async(messages: List[Dict], temperature=0.3, max_tokens=56000):
|
||
async with SEM:
|
||
for attempt in range(6):
|
||
try:
|
||
start = time.time()
|
||
resp = await client.chat.completions.create(
|
||
model=VLLM_MODEL_NAME,
|
||
messages=messages,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
top_p=0.95,
|
||
presence_penalty=1.08,
|
||
)
|
||
content = resp.choices[0].message.content.strip()
|
||
took = time.time() - start
|
||
print(f" √ 成功 | ≈{len(content)//4}千字 | 用时 {took:.1f}s")
|
||
return content
|
||
except Exception as e:
|
||
print(f" × 重试 {attempt+1}/6:{e}")
|
||
if attempt < 5:
|
||
await asyncio.sleep(8)
|
||
else:
|
||
return "【请求失败,已保底】"
|
||
|
||
# ==================== 1. Word → Markdown ====================
|
||
def word_to_md(word_path: str) -> str:
|
||
print("\n开始 1. Word → Markdown 转换...")
|
||
start = time.time()
|
||
md_path = os.path.splitext(word_path)[0] + "_tender.md"
|
||
doc = Document(word_path)
|
||
text = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||
Path(md_path).write_text(text, encoding="utf-8")
|
||
print(f"1. 转换完成!用时 {time.time()-start:.1f}s → {md_path}")
|
||
return md_path
|
||
|
||
# ==================== 2. 并发四级目录 ====================
|
||
async def generate_full_outline_async(tender_md: str) -> str:
|
||
print("\n开始 2. 生成超级四级目录...")
|
||
tender_text = Path(tender_md).read_text(encoding="utf-8")
|
||
|
||
# 生成三级目录骨架(prompt1 优化版)
|
||
prompt1 = f"""你现在是投标文件合规总负责人,唯一目标是让投标文件100%通过形式审查和符合性审查,不被废标。
|
||
|
||
任务:输出一个【完全符合招标文件规定】的投标文件总目录(最多到四级)。
|
||
|
||
绝对铁律(任何违反都会导致废标):
|
||
1. 目录的章节顺序、层级符号(一、 二、 1. 1.1 1.1.1等)、标题文字(包括标点、空格)必须与招标文件《投标文件格式》《投标文件组成》《投标文件编制要求》或其附件一字不差。禁止任何改动、增删、合并、同义词替换。
|
||
2. 如果招标文件明确提供了目录模板或提纲,必须逐字逐句照抄,不允许任何改动。
|
||
3. 只允许在招标文件明确要求提交特定表格/清单的章节下添加对应的四级表格标题,且表格标题文字必须100%来自招标文件或其附件原文。
|
||
4. 表格编号必须与招标文件一致(如表1-1、表3.2-1),不得自编。
|
||
5. 页码一律留空或写“(页码)”,严禁填写数字。
|
||
6. 如有正副本、分册要求,必须在最前面或最后明确标明(如“正本”“副本”“共X册 第X册”)。
|
||
7. 严禁出现任何招标文件未明确要求的章节或表格。
|
||
|
||
招标文件原文(必须严格遵守的全部目录及表格要求):
|
||
{tender_text[:60000]}
|
||
|
||
请直接输出完整的投标文件目录(包括必要的四级表格标题),不要任何解释、说明、引号、markdown标记。"""
|
||
|
||
outline_skeleton = await call_llm_async([{"role": "user", "content": prompt1}], temperature=0.01, max_tokens=10000)
|
||
|
||
level3_titles = [line.strip() for line in outline_skeleton.splitlines()
|
||
if re.match(r'^\d+\.\d+[、 ]', line.strip())]
|
||
|
||
print(f" 检测到 {len(level3_titles)} 个三级标题,开始并发展开四级...")
|
||
|
||
tracker = ProgressTracker((len(level3_titles) + 7) // 8, "2. 四级目录展开进度")
|
||
results = [""] * ((len(level3_titles) + 7) // 8)
|
||
task_to_idx = {}
|
||
|
||
async def on_done(fut, idx):
|
||
results[idx] = await fut
|
||
tracker.update()
|
||
|
||
for i in range(0, len(level3_titles), 8):
|
||
batch = level3_titles[i:i+8]
|
||
batch_text = "\n".join(batch)
|
||
ref_text = tender_text[:50000]
|
||
|
||
# 四级表格展开批处理提示词(prompt2 优化版,更安全)
|
||
prompt2 = f"""根据招标文件要求,只在明确写明“投标人应提交XX表”“填写附件X格式”“提供XX清单”的章节下,插入对应的四级表格标题。
|
||
|
||
绝对禁止:
|
||
- 自创新表格或标题
|
||
- 修改招标文件原文的任何文字
|
||
- 在未要求的地方添加表格
|
||
|
||
允许的操作:
|
||
仅当招标文件原文出现类似“投标人应提交……表(格式见附件X)”“……一览表”“……清单”等语句时,才添加对应表格标题作为四级目录。
|
||
|
||
输出要求:
|
||
- 每行格式:原章节号 + 空格 + 招标文件原文中的完整表格标题(含表号,如表3-1)
|
||
- 用空行分隔不同章节的表格组
|
||
- 如果某个批次的三级章节下没有必须的表格,则输出空字符串即可
|
||
|
||
本批次需要处理的章节标题(保持原顺序):
|
||
{batch_text}
|
||
|
||
招标文件相关原文摘录(包含所有表格要求):
|
||
{ref_text}
|
||
|
||
直接输出四级表格标题,不要任何解释。"""
|
||
|
||
task = asyncio.create_task(call_llm_async([{"role": "user", "content": prompt2}], temperature=0.2, max_tokens=20000))
|
||
task_to_idx[task] = i // 8
|
||
task.add_done_callback(lambda t, idx=i//8: asyncio.create_task(on_done(t, idx)))
|
||
|
||
while [t for t in task_to_idx if not t.done()]:
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 关键修复:只追加四级内容,不重复三级标题
|
||
full_outline = outline_skeleton + "\n\n" + "\n".join(filter(None, results)).strip() + "\n"
|
||
tracker.finish()
|
||
|
||
outline_path = f"{PROCESS_DIR}/四级目录.md"
|
||
Path(outline_path).write_text(full_outline, encoding="utf-8")
|
||
print(f"四级目录已保存 → {outline_path}")
|
||
return full_outline
|
||
|
||
# ==================== 3. 并发正文生成 ====================
|
||
async def batch_fill_content_async(outline: str, tender_text: str) -> str:
|
||
print("\n开始 3. 分批生成正文内容...")
|
||
level4_titles = [line.strip() for line in outline.splitlines()
|
||
if re.match(r'^\d+\.\d+\.\d+[、 ]', line.strip())]
|
||
|
||
batch_size = 8 # 表格减少后,8个一批更稳定
|
||
total_batches = (len(level4_titles) + batch_size - 1) // batch_size
|
||
print(f" 共 {len(level4_titles)} 个四级标题,分 {total_batches} 批处理")
|
||
|
||
tracker = ProgressTracker(total_batches, "3. 正文内容生成进度")
|
||
results = [""] * total_batches
|
||
task_to_idx = {}
|
||
|
||
async def on_done(fut, idx):
|
||
results[idx] = await fut
|
||
tracker.update()
|
||
|
||
for i in range(0, len(level4_titles), batch_size):
|
||
batch = level4_titles[i:i+batch_size]
|
||
titles_str = "\n".join(batch)
|
||
ref_text = tender_text[:60000]
|
||
|
||
prompt = f"""你现在是拥有25年投标经验的合规总师,目标:让投标文件100%通过初步评审(形式审查+符合性审查+资格审查),绝不废标。
|
||
|
||
请为以下章节撰写极简、极干、绝对安全的正文内容,每节总字数严格控制在80-400字。
|
||
|
||
废标红线(任何一条违反都会废标,严禁触碰):
|
||
1. 只能响应招标文件明确要求的内容,多一个字、一个承诺、一个“亮点”都不允许。
|
||
2. 严禁出现“优于招标要求”“优选方案”“创新”“国内领先”“独家”等任何可能被认定为正偏离或多余承诺的字眼。
|
||
3. 所有表格必须100%使用招标文件或附件提供的原表格格式(表头、单位、列数、行数、备注文字一字不改),只允许填写数据。
|
||
4. 正文文字只能是“本公司完全响应招标文件要求”“详见后附证明材料复印件”“见下表”等最安全的套话。
|
||
5. 只能使用以下9个占位符(其他一律删除,不得出现具体公司名、金额等):
|
||
【投标单位全称】【法定代表人姓名】【委托代理人姓名】【项目名称】【投标总价(大写)】【投标总价(小写)】【总服务期(天)】【投标保证金金额】【投标文件签署日期】
|
||
6. 严禁出现任何图片、流程图、彩色文字、组织机构图、超过两段的文字说明。
|
||
|
||
不同类型章节的唯一允许写法(必须严格选择其一):
|
||
• 投标函及附录、投标一览表类:只填招标文件提供的表格 + 一句“以上内容真实有效,完全响应招标文件所有实质性要求和条件,无任何偏差。”
|
||
• 法定代表人身份证明、授权委托书:严格按招标文件提供的格式原文填写(含“身份证号码”等栏),一字不改。
|
||
• 资质、资格证明文件类:只写“详见本节后附证明材料复印件(加盖公章)”。
|
||
• 业绩表、人员表、设备表等所有表格类:只输出完整表格,不加任何文字说明。
|
||
• 承诺书类:严格按招标文件提供的格式或范文填写,最多加一句“本公司完全响应招标文件要求”。
|
||
|
||
当前必须严格按顺序撰写的章节标题:
|
||
{titles_str}
|
||
|
||
招标文件强制要求的内容及所有表格格式原文:
|
||
{ref_text}
|
||
|
||
直接输出每个章节的正文内容,用
|
||
|
||
---
|
||
|
||
分隔,不要任何章节标题、解释、编号。"""
|
||
|
||
task = asyncio.create_task(call_llm_async([{"role": "user", "content": prompt}], temperature=0.35, max_tokens=56000))
|
||
task_to_idx[task] = i // batch_size
|
||
task.add_done_callback(lambda t, idx=i//batch_size: asyncio.create_task(on_done(t, idx)))
|
||
|
||
while [t for t in task_to_idx if not t.done()]:
|
||
await asyncio.sleep(0.1)
|
||
|
||
tracker.finish()
|
||
# 关键修复:去掉无用标题,直接拼接
|
||
final_content = "\n\n---\n\n".join(results)
|
||
content_path = f"{PROCESS_DIR}/正文内容.md"
|
||
Path(content_path).write_text(final_content, encoding="utf-8")
|
||
print(f"正文内容已保存 → {content_path}")
|
||
return final_content
|
||
|
||
# ==================== 4. Markdown → Word(简单占坑) ====================
|
||
def md_to_word(md_path: str, output_word: str):
|
||
print("\n开始 4. Markdown → Word 转换...")
|
||
print("提示:请使用 Pandoc + 模板转换为精美 Word")
|
||
print(f"推荐命令:pandoc \"{md_path}\" -o \"{output_word}\" --reference-doc=模板.docx")
|
||
print("暂未自动执行,需手动运行以上命令或自行补全转换逻辑")
|
||
# 如需自动,可取消下方注释(需先安装 pandoc)
|
||
# import subprocess
|
||
# subprocess.run(['pandoc', md_path, '-o', output_word, '--reference-doc=模板.docx'])
|
||
|
||
# ==================== 主流程 ====================
|
||
async def main_async():
|
||
# 统一创建目录,永绝路径错误
|
||
os.makedirs(PROCESS_DIR, exist_ok=True)
|
||
os.makedirs("output", exist_ok=True)
|
||
|
||
tender_md = word_to_md(INPUT_WORD)
|
||
tender_text = Path(tender_md).read_text(encoding="utf-8")
|
||
|
||
outline = await generate_full_outline_async(tender_md)
|
||
content = await batch_fill_content_async(outline, tender_text)
|
||
|
||
final_md = f"""# 【投标单位全称】
|
||
|
||
## {Path(INPUT_WORD).stem} - 投标文件
|
||
|
||
{outline}
|
||
|
||
{content}"""
|
||
|
||
final_md_path = f"{PROCESS_DIR}/最终投标文件.md"
|
||
Path(final_md_path).write_text(final_md, encoding="utf-8")
|
||
print(f"\n最终整合完成 → {final_md_path}")
|
||
|
||
md_to_word(final_md_path, OUTPUT_WORD)
|
||
|
||
total_seconds = time.time() - TOTAL_START
|
||
print("\n" + "="*90)
|
||
print("大功告成!顶级投标文件已生成!")
|
||
print(f"总耗时:{total_seconds/60:.2f} 分钟")
|
||
print(f"最终 Word 文件路径:{OUTPUT_WORD}")
|
||
print(f"所有过程文件保存在:{PROCESS_DIR}/")
|
||
print("="*90)
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main_async()) |