commit 8677bc0086fa6b9cf4895449e8883c94fdb2c991 Author: wanghui Date: Mon Apr 20 16:21:06 2026 +0800 技术标最新版本 diff --git a/.deps_installed b/.deps_installed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.deps_installed @@ -0,0 +1 @@ + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a7e8c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +*.log +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..f6906f2 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..260df91 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/tech-bid-manage.iml b/.idea/tech-bid-manage.iml new file mode 100644 index 0000000..099d7c0 --- /dev/null +++ b/.idea/tech-bid-manage.iml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9945ab --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# 标伙伴 · AI 标书助手 + +基于大模型的智能标书生成工具(单机版),支持解析招标文件、自动生成技术标书、导出 Word 文档。 + +## 快速开始 + +### 方式一:双击启动(Windows) + +直接双击 `start.bat`,首次运行会自动安装依赖。 + +### 方式二:命令行启动 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 启动应用 +python app.py +``` + +浏览器访问 **http://localhost:5000** + +--- + +## 配置 API Key + +首次使用前,点击右上角 ⚙️ 设置图标,选择模型提供商并填入 API Key: + +| 提供商 | 推荐模型 | 申请地址 | +|--------|---------|---------| +| 通义千问 | qwen-max | https://dashscope.aliyun.com/ | +| DeepSeek | deepseek-chat (V3) | https://platform.deepseek.com/ | +| OpenAI | gpt-4o | https://platform.openai.com/ | + +> **DeepSeek 说明**:deepseek-chat (V3) 性价比极高,推荐用于生产环境。 +> 由于 DeepSeek 暂不提供 Embedding API,使用知识库功能时会自动回退到本地 sentence-transformers 模型(首次使用需下载约 90MB)。 + +也可通过环境变量配置: + +```bash +# 通义千问 +set QWEN_API_KEY=sk-xxxxxxxx +set MODEL_PROVIDER=qwen + +# DeepSeek +set DEEPSEEK_API_KEY=sk-xxxxxxxx +set MODEL_PROVIDER=deepseek + +python app.py +``` + +--- + +## 使用流程 + +1. **新建项目** → 输入项目名称 +2. **上传招标文件** → 支持 PDF / DOC / DOCX +3. **AI 解析** → 自动提取评分要求、资质条件、商务条款 +4. **生成大纲** → 按评分权重生成四级章节目录 +5. **生成内容** → 逐章节或一键全部生成 +6. **合规检查** → 对照招标要求检验覆盖情况 +7. **导出 Word** → 专业排版,直接使用 + +--- + +## 目录结构 + +``` +autorfp/ +├── app.py # Flask 主程序 +├── config.py # 配置文件 +├── requirements.txt # Python 依赖 +├── start.bat # Windows 一键启动 +├── prompts/ # AI 提示词模板 +├── modules/ # 功能模块 +│ ├── parser.py # 招标文件解析 +│ ├── generator.py # 标书内容生成 +│ ├── checker.py # 合规检查 +│ ├── exporter.py # Word 导出 +│ └── knowledge.py # 企业知识库 +├── utils/ # 工具函数 +│ ├── ai_client.py # AI API 封装 +│ ├── file_utils.py # 文件处理 +│ └── prompts.py # 提示词加载 +├── templates/ # HTML 模板 +├── static/ # 静态资源 +└── data/ # 数据目录(自动创建) + ├── projects.db # SQLite 数据库 + ├── uploads/ # 上传的招标文件 + ├── exports/ # 导出的标书 + ├── knowledge/ # 知识库文件 + └── chroma/ # 向量数据库 +``` + +--- + +## 企业知识库 + +在项目页面切换到「知识库」标签,上传历史标书文件。 +系统会自动将文件分块存入向量数据库,生成内容时自动检索相关片段,让 AI 更好地体现企业优势。 + +--- + +## 常见问题 + +**Q: 解析速度很慢?** +A: 招标文件越长耗时越长,通常 30-120 秒。建议使用 qwen-max 或 gpt-4o。 + +**Q: 内容生成失败?** +A: 检查 API Key 是否正确,以及账户余额是否充足。 + +**Q: 导出的 Word 文件乱码?** +A: 请使用 Microsoft Word 2016 及以上版本打开。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..632e04e --- /dev/null +++ b/app.py @@ -0,0 +1,1074 @@ +""" +标伙伴 - AI 标书助手(单机版) +启动命令:python app.py +访问地址:http://localhost:5000 +""" +import os +import sys + + +def _bootstrap_env_file(): + """在 import config 之前加载项目根目录 .env,便于注入 API Key;不覆盖已存在的环境变量。""" + if getattr(sys, 'frozen', False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(base, '.env') + if not os.path.isfile(path): + return + try: + with open(path, encoding='utf-8') as f: + for raw in f: + line = raw.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, _, val = line.partition('=') + key, val = key.strip(), val.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = val + except OSError: + pass + + +_bootstrap_env_file() +import json +import sqlite3 +import threading +import logging +from datetime import datetime +from flask import Flask, request, jsonify, render_template, send_from_directory, abort + +import config +from utils import settings as _settings + +# ── 日志配置 ──────────────────────────────────────────────────────────────── +_log_handlers = [logging.StreamHandler()] +if getattr(sys, 'frozen', False): + _log_file = os.path.join(os.path.dirname(sys.executable), 'bid_partner.log') + try: + _log_handlers.append(logging.FileHandler(_log_file, encoding='utf-8')) + except Exception: + pass + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%H:%M:%S', + handlers=_log_handlers, +) +logger = logging.getLogger(__name__) + + +def _safe_json_load(raw): + if not raw or not isinstance(raw, str): + return None + try: + return json.loads(raw) + except Exception: + return None + + +# ── Flask 应用 ─────────────────────────────────────────────────────────────── +_bundle = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) +app = Flask(__name__, + template_folder=os.path.join(_bundle, 'templates'), + static_folder=os.path.join(_bundle, 'static')) +app.secret_key = config.SECRET_KEY +app.config['MAX_CONTENT_LENGTH'] = config.MAX_FILE_SIZE_MB * 1024 * 1024 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 数据库初始化 +# ═══════════════════════════════════════════════════════════════════════════ + +def init_db(): + """创建所有必要的目录和数据库表""" + for d in [config.DATA_DIR, config.UPLOAD_DIR, config.EXPORT_DIR, + config.KNOWLEDGE_DIR, config.CHROMA_DIR]: + os.makedirs(d, exist_ok=True) + + # 初始化持久化配置,启动时恢复上次保存的 API Key 等设置 + settings_path = os.path.join(config.DATA_DIR, 'settings.json') + _settings.init(settings_path) + _settings.load(config) + logger.info(f'当前模型: {config.MODEL_PROVIDER}') + + conn = sqlite3.connect(config.DB_PATH) + cur = conn.cursor() + # WAL 模式:允许多个读写线程并发操作,不互相阻塞 + cur.execute('PRAGMA journal_mode=WAL') + cur.execute('PRAGMA synchronous=NORMAL') # WAL 下可适当降低同步级别以提速 + + cur.executescript(''' + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + outline_status TEXT DEFAULT 'none', + outline_error TEXT DEFAULT '', + anon_requirements TEXT DEFAULT '', + enable_figure INTEGER DEFAULT 0, + enable_table INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS tender_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL UNIQUE, + file_name TEXT, + raw_text TEXT, + summary TEXT, + rating_requirements TEXT, + rating_json TEXT, + outline TEXT, + boq_file_name TEXT DEFAULT '', + boq_text TEXT DEFAULT '', + boq_summary TEXT DEFAULT '', + boq_analysis_json TEXT DEFAULT '', + boq_status TEXT DEFAULT 'none', + boq_error TEXT DEFAULT '', + tender_kind TEXT DEFAULT 'engineering', + status TEXT DEFAULT 'pending', + error_message TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS bid_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + section_number TEXT, + section_title TEXT NOT NULL, + level INTEGER DEFAULT 1, + is_leaf INTEGER DEFAULT 1, + content TEXT DEFAULT '', + intro_content TEXT DEFAULT '', + order_index INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + error_message TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS knowledge_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL UNIQUE, + file_path TEXT, + chunk_count INTEGER DEFAULT 0, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ''') + conn.commit() + # 兼容旧数据库:追加新列(已存在时忽略错误) + migrations = [ + ("ALTER TABLE projects ADD COLUMN anon_requirements TEXT DEFAULT ''", + 'projects.anon_requirements'), + ("ALTER TABLE projects ADD COLUMN enable_figure INTEGER DEFAULT 0", + 'projects.enable_figure'), + ("ALTER TABLE projects ADD COLUMN enable_table INTEGER DEFAULT 0", + 'projects.enable_table'), + ("ALTER TABLE tender_data ADD COLUMN boq_file_name TEXT DEFAULT ''", + 'tender_data.boq_file_name'), + ("ALTER TABLE tender_data ADD COLUMN boq_text TEXT DEFAULT ''", + 'tender_data.boq_text'), + ("ALTER TABLE tender_data ADD COLUMN boq_summary TEXT DEFAULT ''", + 'tender_data.boq_summary'), + ("ALTER TABLE tender_data ADD COLUMN boq_status TEXT DEFAULT 'none'", + 'tender_data.boq_status'), + ("ALTER TABLE tender_data ADD COLUMN boq_error TEXT DEFAULT ''", + 'tender_data.boq_error'), + ("ALTER TABLE tender_data ADD COLUMN boq_analysis_json TEXT DEFAULT ''", + 'tender_data.boq_analysis_json'), + ("ALTER TABLE tender_data ADD COLUMN tender_kind TEXT DEFAULT 'engineering'", + 'tender_data.tender_kind'), + ] + for sql, col in migrations: + try: + conn.execute(sql) + conn.commit() + logger.info(f'数据库迁移:新增 {col} 列') + except Exception: + pass # 列已存在 + conn.close() + logger.info('数据库初始化完成') + + +def get_db(): + return sqlite3.connect(config.DB_PATH) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 页面路由 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/project/') +def project_page(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT id, name, created_at FROM projects WHERE id=?", (project_id,)) + row = cur.fetchone() + conn.close() + if not row: + abort(404) + return render_template('project.html', project={'id': row[0], 'name': row[1], 'created_at': row[2]}) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:项目管理 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/projects', methods=['GET']) +def api_list_projects(): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT p.id, p.name, p.created_at, p.outline_status, + td.status as parse_status, td.file_name, + (SELECT COUNT(*) FROM bid_sections WHERE project_id=p.id) as section_count, + (SELECT COUNT(*) FROM bid_sections WHERE project_id=p.id AND status='done') as done_count + FROM projects p + LEFT JOIN tender_data td ON td.project_id = p.id + ORDER BY p.created_at DESC + ''') + rows = cur.fetchall() + conn.close() + projects = [] + for r in rows: + projects.append({ + 'id': r[0], 'name': r[1], 'created_at': r[2], + 'outline_status': r[3], 'parse_status': r[4] or 'none', + 'file_name': r[5], 'section_count': r[6], 'done_count': r[7], + }) + return jsonify({'projects': projects}) + + +@app.route('/api/projects', methods=['POST']) +def api_create_project(): + data = request.get_json() + name = (data or {}).get('name', '').strip() + if not name: + return jsonify({'error': '项目名称不能为空'}), 400 + conn = get_db() + cur = conn.cursor() + cur.execute("INSERT INTO projects (name) VALUES (?)", (name,)) + project_id = cur.lastrowid + conn.commit() + conn.close() + return jsonify({'id': project_id, 'name': name}), 201 + + +@app.route('/api/projects/', methods=['DELETE']) +def api_delete_project(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute("DELETE FROM projects WHERE id=?", (project_id,)) + conn.commit() + conn.close() + return jsonify({'success': True}) + + +@app.route('/api/projects/', methods=['GET']) +def api_get_project(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT p.id, p.name, p.created_at, p.outline_status, p.outline_error, + td.file_name, td.status as parse_status, td.error_message, + td.summary, td.rating_requirements, td.rating_json, td.outline, + p.anon_requirements, p.enable_figure, p.enable_table, + td.boq_file_name, td.boq_summary, td.boq_status, td.boq_error, + td.boq_analysis_json, td.tender_kind + FROM projects p + LEFT JOIN tender_data td ON td.project_id = p.id + WHERE p.id=? + ''', (project_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'error': '项目不存在'}), 404 + + return jsonify({ + 'id': row[0], 'name': row[1], 'created_at': row[2], + 'outline_status': row[3], 'outline_error': row[4], + 'file_name': row[5], 'parse_status': row[6] or 'none', + 'parse_error': row[7], 'summary': row[8], + 'rating_requirements': row[9], 'rating_json': row[10], + 'outline': row[11], 'anon_requirements': row[12] or '', + 'enable_figure': bool(row[13]), 'enable_table': bool(row[14]), + 'boq_file_name': row[15] or '', 'boq_summary': row[16] or '', + 'boq_status': row[17] or 'none', 'boq_error': row[18] or '', + 'boq_analysis': _safe_json_load(row[19]), + 'tender_kind': row[20] or 'engineering', + }) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:文件上传与解析 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/projects//upload', methods=['POST']) +def api_upload(project_id): + from utils.file_utils import allowed_file, safe_filename + + if 'file' not in request.files: + return jsonify({'error': '未选择文件'}), 400 + f = request.files['file'] + if not f.filename: + return jsonify({'error': '文件名为空'}), 400 + if not allowed_file(f.filename): + return jsonify({'error': '仅支持 PDF / DOC / DOCX 格式'}), 400 + + filename = safe_filename(f.filename) + save_path = os.path.join(config.UPLOAD_DIR, f'{project_id}_{filename}') + f.save(save_path) + + # 初始化 tender_data 记录 + conn = get_db() + cur = conn.cursor() + cur.execute(''' + INSERT INTO tender_data (project_id, file_name, status) + VALUES (?, ?, 'uploaded') + ON CONFLICT(project_id) DO UPDATE SET file_name=?, status='uploaded', error_message='', updated_at=? + ''', (project_id, filename, filename, datetime.now())) + conn.commit() + conn.close() + + return jsonify({'success': True, 'file_name': filename, 'path': save_path}) + + +@app.route('/api/projects//tender-data', methods=['PUT']) +def api_update_tender_data(project_id): + """允许用户手动修改并保存解析结果(摘要、技术评分要求、标书类型)""" + data = request.get_json() or {} + fields = {} + if 'summary' in data: + fields['summary'] = data['summary'] + if 'rating_requirements' in data: + fields['rating_requirements'] = data['rating_requirements'] + if 'tender_kind' in data: + tk = (data.get('tender_kind') or 'engineering').strip().lower() + if tk not in ('engineering', 'service', 'goods'): + return jsonify({'error': 'tender_kind 须为 engineering / service / goods'}), 400 + fields['tender_kind'] = tk + if not fields: + return jsonify({'error': '无可更新字段'}), 400 + + conn = get_db() + cur = conn.cursor() + set_clause = ', '.join(f'{k}=?' for k in fields) + values = list(fields.values()) + [datetime.now(), project_id] + cur.execute( + f'UPDATE tender_data SET {set_clause}, updated_at=? WHERE project_id=?', + values + ) + conn.commit() + conn.close() + return jsonify({'success': True}) + + +@app.route('/api/projects//upload-boq', methods=['POST']) +def api_upload_boq(project_id): + """上传工程量清单文件(独立于招标文件)""" + from utils.file_utils import safe_filename + + if 'file' not in request.files: + return jsonify({'error': '未选择文件'}), 400 + f = request.files['file'] + if not f.filename: + return jsonify({'error': '文件名为空'}), 400 + + ext = os.path.splitext(f.filename)[1].lower() + allowed_exts = {'.xlsx', '.xls', '.csv', '.pdf', '.docx', '.doc'} + if ext not in allowed_exts: + return jsonify({'error': f'不支持的格式 {ext},请使用 xlsx/xls/csv/pdf/docx/doc'}), 400 + + filename = safe_filename(f.filename) + save_path = os.path.join(config.UPLOAD_DIR, f'{project_id}_boq_{filename}') + f.save(save_path) + + # 确保 tender_data 记录存在 + conn = get_db() + cur = conn.cursor() + cur.execute(''' + INSERT INTO tender_data (project_id, boq_file_name, boq_status) + VALUES (?, ?, 'uploaded') + ON CONFLICT(project_id) DO UPDATE + SET boq_file_name=?, boq_status='uploaded', boq_error='', updated_at=? + ''', (project_id, filename, filename, datetime.now())) + conn.commit() + conn.close() + + return jsonify({'success': True, 'file_name': filename, 'path': save_path}) + + +@app.route('/api/projects//parse-boq', methods=['POST']) +def api_parse_boq(project_id): + """后台解析工程量清单 → AI 摘要""" + from modules.parser import parse_boq_file + + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT boq_file_name FROM tender_data WHERE project_id=?", (project_id,)) + row = cur.fetchone() + conn.close() + + if not row or not row[0]: + return jsonify({'error': '请先上传工程量清单文件'}), 400 + + file_name = row[0] + # 同时尝试带/不带 boq_ 前缀的路径 + path1 = os.path.join(config.UPLOAD_DIR, f'{project_id}_boq_{file_name}') + path2 = os.path.join(config.UPLOAD_DIR, f'{project_id}_{file_name}') + file_path = path1 if os.path.exists(path1) else path2 + if not os.path.exists(file_path): + return jsonify({'error': '清单文件不存在,请重新上传'}), 404 + + t = threading.Thread( + target=parse_boq_file, + args=(config.DB_PATH, project_id, file_path, file_name), + daemon=True, + ) + t.start() + return jsonify({'success': True}) + + +@app.route('/api/projects//boq', methods=['PUT']) +def api_update_boq(project_id): + """手动保存用户编辑后的工程量清单摘要""" + data = request.get_json() or {} + boq_summary = data.get('boq_summary', '') + conn = get_db() + cur = conn.cursor() + cur.execute( + "UPDATE tender_data SET boq_summary=?, updated_at=? WHERE project_id=?", + (boq_summary, datetime.now(), project_id) + ) + conn.commit() + conn.close() + return jsonify({'success': True}) + + +@app.route('/api/projects//parse', methods=['POST']) +def api_parse(project_id): + from modules.parser import parse_tender_file + + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT file_name FROM tender_data WHERE project_id=?", (project_id,)) + row = cur.fetchone() + conn.close() + + if not row or not row[0]: + return jsonify({'error': '请先上传招标文件'}), 400 + + file_name = row[0] + file_path = os.path.join(config.UPLOAD_DIR, f'{project_id}_{file_name}') + if not os.path.exists(file_path): + return jsonify({'error': f'文件不存在: {file_name}'}), 404 + + t = threading.Thread( + target=parse_tender_file, + args=(config.DB_PATH, project_id, file_path, file_name), + daemon=True, + ) + t.start() + return jsonify({'success': True, 'message': '解析任务已启动'}) + + +@app.route('/api/projects//parse-status', methods=['GET']) +def api_parse_status(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT status, error_message, summary, rating_requirements, rating_json, tender_kind " + "FROM tender_data WHERE project_id=?", + (project_id,) + ) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'status': 'none'}) + return jsonify({ + 'status': row[0], + 'message': row[1], + 'has_summary': bool(row[2]), + 'has_rating': bool(row[3]), + 'has_rating_json': bool(row[4]), + 'tender_kind': row[5] or 'engineering', + }) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:大纲生成 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/projects//outline', methods=['PUT']) +def api_update_outline(project_id): + """ + 用户手动修改大纲后保存:重新解析大纲文本,更新 bid_sections。 + 注意:已生成的章节内容将被清除,需重新生成。 + """ + from modules.generator import _parse_outline, _save_sections, _save_outline_text + + data = request.get_json() or {} + outline_text = (data.get('outline') or '').strip() + if not outline_text: + return jsonify({'error': '大纲内容不能为空'}), 400 + + try: + # 解析并自动重排序号,返回规范化文本 + _, sections, normalized_text = _parse_outline(outline_text) + if not sections: + return jsonify({'error': '大纲解析失败,未识别到任何章节,请检查格式'}), 400 + + conn = get_db() + cur = conn.cursor() + # 存储重排序号后的规范文本 + _save_outline_text(conn, project_id, normalized_text) + _save_sections(conn, project_id, sections) + cur.execute( + "UPDATE projects SET outline_status='outline_done', outline_error='', updated_at=? WHERE id=?", + (datetime.now(), project_id) + ) + cur.execute("SELECT length(outline) FROM tender_data WHERE project_id=?", (project_id,)) + persisted_len = (cur.fetchone() or [0])[0] or 0 + conn.commit() + conn.close() + # 把规范化文本返回给前端,前端据此更新编辑器内容 + return jsonify({ + 'success': True, + 'section_count': len(sections), + 'normalized_outline': normalized_text, + 'persisted_outline_len': persisted_len, + }) + except Exception as e: + logger.exception('手动保存大纲失败') + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/projects//generate-outline', methods=['POST']) +def api_generate_outline(project_id): + from modules.generator import generate_outline + data = request.get_json(silent=True) or {} + force = bool(data.get('force', False)) + + if not force: + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT outline FROM tender_data WHERE project_id=?", (project_id,)) + row = cur.fetchone() + conn.close() + has_outline = bool((row[0] if row else '') or '') + if has_outline: + return jsonify({ + 'success': False, + 'error': '当前项目已有大纲,重新生成会覆盖现有大纲。请确认后以 force=true 再次请求。' + }), 409 + + t = threading.Thread( + target=generate_outline, + args=(config.DB_PATH, project_id), + daemon=True, + ) + t.start() + return jsonify({'success': True, 'message': '大纲生成任务已启动'}) + + +@app.route('/api/projects//expand-outline', methods=['POST']) +def api_expand_outline(project_id): + """根据当前编辑大纲自动补全小章节,并直接落库重建章节树。""" + from modules.generator import ( + expand_outline, + _parse_outline, + _save_outline_text, + _save_sections, + ) + + data = request.get_json() or {} + outline = data.get('outline', '') + if not outline.strip(): + return jsonify({'success': False, 'error': '大纲内容不能为空'}), 400 + + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT summary, rating_requirements FROM tender_data WHERE project_id=?", + (project_id,), + ) + row = cur.fetchone() + conn.close() + + summary = row[0] if row else '' + rating_requirements = row[1] if row else '' + + try: + expanded_outline = expand_outline(outline, summary, rating_requirements, project_id) + _, sections, normalized_text = _parse_outline(expanded_outline) + if not sections: + return jsonify({'success': False, 'error': '扩充后大纲解析失败,请检查章节格式'}), 400 + + conn = get_db() + cur = conn.cursor() + _save_outline_text(conn, project_id, normalized_text) + _save_sections(conn, project_id, sections) + cur.execute( + "UPDATE projects SET outline_status='outline_done', outline_error='', updated_at=? WHERE id=?", + (datetime.now(), project_id), + ) + conn.commit() + cur.execute("SELECT length(outline) FROM tender_data WHERE project_id=?", (project_id,)) + persisted_len = (cur.fetchone() or [0])[0] or 0 + conn.close() + + return jsonify({ + 'success': True, + 'expanded_outline': expanded_outline, + 'normalized_outline': normalized_text, + 'section_count': len(sections), + 'persisted_outline_len': persisted_len, + }) + except Exception as e: + logger.exception(f'expand_outline failed for project {project_id}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/projects//outline-status', methods=['GET']) +def api_outline_status(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT outline_status, outline_error FROM projects WHERE id=?", (project_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'status': 'none'}) + return jsonify({'status': row[0], 'error': row[1]}) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:章节管理与内容生成 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/projects//sections', methods=['GET']) +def api_list_sections(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT id, section_number, section_title, level, is_leaf, + status, error_message, length(content) as content_len + FROM bid_sections + WHERE project_id=? + ORDER BY order_index + ''', (project_id,)) + rows = cur.fetchall() + conn.close() + sections = [] + for r in rows: + sections.append({ + 'id': r[0], 'number': r[1], 'title': r[2], 'level': r[3], + 'is_leaf': bool(r[4]), 'status': r[5], 'error': r[6], + 'has_content': (r[7] or 0) > 0, + }) + return jsonify({'sections': sections}) + + +@app.route('/api/projects//sections/', methods=['GET']) +def api_get_section(project_id, section_id): + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT id, section_number, section_title, level, is_leaf, content, intro_content, status FROM bid_sections WHERE id=? AND project_id=?", + (section_id, project_id) + ) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'error': '章节不存在'}), 404 + return jsonify({ + 'id': row[0], 'number': row[1], 'title': row[2], 'level': row[3], + 'is_leaf': bool(row[4]), 'content': row[5], 'intro_content': row[6], 'status': row[7], + }) + + +@app.route('/api/projects//sections/', methods=['PUT']) +def api_update_section(project_id, section_id): + data = request.get_json() or {} + content = data.get('content', '') + conn = get_db() + cur = conn.cursor() + cur.execute( + "UPDATE bid_sections SET content=?, status='done', updated_at=? WHERE id=? AND project_id=?", + (content, datetime.now(), section_id, project_id) + ) + conn.commit() + conn.close() + return jsonify({'success': True}) + + +@app.route('/api/projects//sections//chat', methods=['POST']) +def api_section_chat(project_id, section_id): + """ + 对话式章节生成:接受多轮对话历史,结合章节上下文调用 AI,返回新一轮回复。 + 请求体:{ "messages": [{"role": "user"|"assistant", "content": "..."}] } + """ + from utils import ai_client + + data = request.get_json() or {} + messages = data.get('messages', []) + if not messages: + return jsonify({'error': '消息列表不能为空'}), 400 + + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT section_title FROM bid_sections WHERE id=? AND project_id=?", + (section_id, project_id) + ) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'error': '章节不存在'}), 404 + section_title = row[0] + + cur.execute( + "SELECT summary, outline, tender_kind FROM tender_data WHERE project_id=?", + (project_id,), + ) + td = cur.fetchone() + conn.close() + + summary = ((td[0] or '')[:3000]) if td else '' + outline = ((td[1] or '')[:2000]) if td else '' + tk = (td[2] or 'engineering').strip().lower() if td else 'engineering' + if tk not in ('engineering', 'service', 'goods'): + tk = 'engineering' + + from utils.tender_kind_sections import CHAT_KIND_INSTRUCTION + + kind_hint = CHAT_KIND_INSTRUCTION.get(tk, CHAT_KIND_INSTRUCTION['engineering']) + + system = f"""你是一位资深的投标文件撰写专家,正在协助用户以对话方式撰写技术标书中「{section_title}」章节的正文内容。 + +【项目背景摘要】 +{summary or '(未提供)'} + +【标书目录结构】 +{outline or '(未提供)'} +{kind_hint} + +【撰写规范(必须遵守)】 +- 投标方自称统一用"我方",禁用"我们""我公司" +- 禁止套话:综上所述、高度重视、全力以赴、不断优化、稳步推进等 +- 每项措施须有可检验的实质内容(做法、节点、标准编号,或招标文件/清单已给出的量化依据); + 未载明的型号、数量、吨位、时限等不得编造,用概括性定性表述写清含义;禁止使用方括号待填项(如[型号][数量]) +- 列举用(1)(2)(3)编号,禁止"首先其次最后"连接 +- 纯文本输出,段落间用空行分隔,不使用 Markdown 符号 +- 直接输出正文,不含章节标题、解释说明或"以下是..."引导语""" + + valid_messages = [m for m in messages if m.get('role') in ('user', 'assistant')] + + try: + content = ai_client.chat_with_history(system, valid_messages, + temperature=0.7, max_tokens=4096) + return jsonify({'success': True, 'content': content}) + except Exception as e: + logger.exception(f'对话式章节生成失败 section_id={section_id}') + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/projects//generate-section', methods=['POST']) +def api_generate_section(project_id): + from modules.generator import generate_section + + data = request.get_json() or {} + section_id = data.get('section_id') + if not section_id: + return jsonify({'error': '缺少 section_id'}), 400 + + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT anon_requirements, enable_figure, enable_table FROM projects WHERE id=?", + (project_id,) + ) + row = cur.fetchone() + conn.close() + anon_req = (row[0] or '') if row else '' + enable_fig = bool(row[1]) if row else False + enable_tbl = bool(row[2]) if row else False + + t = threading.Thread( + target=generate_section, + args=(config.DB_PATH, project_id, section_id, anon_req, enable_fig, enable_tbl), + daemon=True, + ) + t.start() + return jsonify({'success': True}) + + +@app.route('/api/projects//diagram', methods=['PUT']) +def api_update_diagram(project_id): + """保存图表模式开关""" + data = request.get_json() or {} + enable_figure = 1 if data.get('enable_figure') else 0 + enable_table = 1 if data.get('enable_table') else 0 + conn = get_db() + cur = conn.cursor() + cur.execute( + "UPDATE projects SET enable_figure=?, enable_table=?, updated_at=? WHERE id=?", + (enable_figure, enable_table, datetime.now(), project_id) + ) + conn.commit() + conn.close() + return jsonify({'success': True}) + + +@app.route('/api/projects//anon', methods=['PUT']) +def api_update_anon(project_id): + """保存暗标要求""" + data = request.get_json() or {} + anon_requirements = data.get('anon_requirements', '') + conn = get_db() + cur = conn.cursor() + cur.execute( + "UPDATE projects SET anon_requirements=?, updated_at=? WHERE id=?", + (anon_requirements, datetime.now(), project_id) + ) + conn.commit() + conn.close() + return jsonify({'success': True}) + + +@app.route('/api/projects//generate-all-sections', methods=['POST']) +def api_generate_all_sections(project_id): + from modules.generator import generate_all_sections + + conn = get_db() + cur = conn.cursor() + cur.execute( + "SELECT anon_requirements, enable_figure, enable_table FROM projects WHERE id=?", + (project_id,) + ) + row = cur.fetchone() + conn.close() + anon_req = (row[0] or '') if row else '' + enable_fig = bool(row[1]) if row else False + enable_tbl = bool(row[2]) if row else False + + t = threading.Thread( + target=generate_all_sections, + args=(config.DB_PATH, project_id, anon_req, enable_fig, enable_tbl), + daemon=True, + ) + t.start() + return jsonify({'success': True, 'message': '全量生成任务已启动'}) + + +@app.route('/api/projects//section-progress', methods=['GET']) +def api_section_progress(project_id): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT + COUNT(*) as total, + SUM(CASE WHEN status='done' THEN 1 ELSE 0 END) as done, + SUM(CASE WHEN status='generating' THEN 1 ELSE 0 END) as running, + SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) as error_count + FROM bid_sections WHERE project_id=? + ''', (project_id,)) + r = cur.fetchone() + conn.close() + total, done, running, errors = r + return jsonify({ + 'total': total or 0, 'done': done or 0, + 'running': running or 0, 'errors': errors or 0, + 'percent': round((done or 0) / max(total or 1, 1) * 100), + }) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:合规检查 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/projects//check', methods=['POST']) +def api_check(project_id): + from modules.checker import check_compliance + result = check_compliance(config.DB_PATH, project_id) + return jsonify(result) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:导出 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/projects//export', methods=['POST']) +def api_export(project_id): + from modules.exporter import export_to_word + try: + filename = export_to_word(config.DB_PATH, project_id) + return jsonify({'success': True, 'filename': filename, 'url': f'/api/download/{filename}'}) + except Exception as e: + logger.exception('导出失败') + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/download/') +def api_download(filename): + return send_from_directory(config.EXPORT_DIR, filename, as_attachment=True) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:知识库管理 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/knowledge/status', methods=['GET']) +def api_knowledge_status(): + from modules.knowledge import is_available, list_files + status = is_available() + status['file_count'] = len(list_files(config.DB_PATH)) + return jsonify(status) + + +@app.route('/api/knowledge/files', methods=['GET']) +def api_knowledge_list(): + from modules.knowledge import list_files + files = list_files(config.DB_PATH) + return jsonify({'files': files}) + + +@app.route('/api/knowledge/upload', methods=['POST']) +def api_knowledge_upload(): + from modules.knowledge import add_file + from utils.file_utils import allowed_file, safe_filename + import threading + + if 'file' not in request.files: + return jsonify({'error': '未选择文件'}), 400 + f = request.files['file'] + if not f.filename or not allowed_file(f.filename): + return jsonify({'error': '仅支持 PDF / DOC / DOCX'}), 400 + + filename = safe_filename(f.filename) + save_path = os.path.join(config.KNOWLEDGE_DIR, filename) + f.save(save_path) + + # 后台线程入库(提取文本 + 向量化可能耗时,避免请求超时) + def _ingest(): + result = add_file(save_path, config.DB_PATH) + if not result.get('success'): + logger.error(f'知识库入库失败 {filename}: {result.get("error")}') + + threading.Thread(target=_ingest, daemon=True).start() + + return jsonify({'success': True, 'queued': True, 'filename': filename}) + + +@app.route('/api/knowledge/delete', methods=['POST']) +def api_knowledge_delete(): + from modules.knowledge import delete_file + data = request.get_json() or {} + file_name = data.get('file_name', '') + if not file_name: + return jsonify({'error': '缺少 file_name'}), 400 + result = delete_file(file_name, config.DB_PATH) + return jsonify(result) + + +# ═══════════════════════════════════════════════════════════════════════════ +# API:AI 配置 +# ═══════════════════════════════════════════════════════════════════════════ + +@app.route('/api/config', methods=['GET']) +def api_get_config(): + def _has_key(k): return bool(k and not k.startswith('sk-your')) + return jsonify({ + 'model_provider': config.MODEL_PROVIDER, + 'qwen_model': config.QWEN_MODEL, + 'qwen_base_url': config.QWEN_BASE_URL, + 'openai_model': config.OPENAI_MODEL, + 'openai_base_url': config.OPENAI_BASE_URL, + 'deepseek_model': config.DEEPSEEK_MODEL, + 'deepseek_base_url': config.DEEPSEEK_BASE_URL, + 'ollama_base_url': config.OLLAMA_BASE_URL, + 'ollama_model': config.OLLAMA_MODEL, + 'doubao_model': config.DOUBAO_MODEL, + 'doubao_base_url': config.DOUBAO_BASE_URL, + 'kimi_model': config.KIMI_MODEL, + 'kimi_base_url': config.KIMI_BASE_URL, + 'has_qwen_key': _has_key(config.QWEN_API_KEY), + 'has_openai_key': _has_key(config.OPENAI_API_KEY), + 'has_deepseek_key': _has_key(config.DEEPSEEK_API_KEY), + 'has_doubao_key': _has_key(config.DOUBAO_API_KEY), + 'has_kimi_key': _has_key(config.KIMI_API_KEY), + 'max_concurrent': config.MAX_CONCURRENT_SECTIONS, + 'content_volume': config.CONTENT_VOLUME, + }) + + +@app.route('/api/config', methods=['POST']) +def api_save_config(): + data = request.get_json() or {} + if 'model_provider' in data: + config.MODEL_PROVIDER = data['model_provider'] + if 'qwen_api_key' in data and data['qwen_api_key']: + config.QWEN_API_KEY = data['qwen_api_key'] + if 'qwen_model' in data and data['qwen_model']: + config.QWEN_MODEL = data['qwen_model'] + if 'qwen_base_url' in data and data['qwen_base_url']: + config.QWEN_BASE_URL = data['qwen_base_url'] + if 'openai_api_key' in data and data['openai_api_key']: + config.OPENAI_API_KEY = data['openai_api_key'] + if 'openai_model' in data and data['openai_model']: + config.OPENAI_MODEL = data['openai_model'] + if 'openai_base_url' in data and data['openai_base_url']: + config.OPENAI_BASE_URL = data['openai_base_url'] + if 'deepseek_api_key' in data and data['deepseek_api_key']: + config.DEEPSEEK_API_KEY = data['deepseek_api_key'] + if 'deepseek_model' in data and data['deepseek_model']: + config.DEEPSEEK_MODEL = data['deepseek_model'] + if 'deepseek_base_url' in data and data['deepseek_base_url']: + config.DEEPSEEK_BASE_URL = data['deepseek_base_url'] + if 'ollama_base_url' in data and data['ollama_base_url']: + config.OLLAMA_BASE_URL = data['ollama_base_url'] + if 'ollama_model' in data and data['ollama_model']: + config.OLLAMA_MODEL = data['ollama_model'] + if 'doubao_api_key' in data and data['doubao_api_key']: + config.DOUBAO_API_KEY = data['doubao_api_key'] + if 'doubao_model' in data and data['doubao_model']: + config.DOUBAO_MODEL = data['doubao_model'] + if 'doubao_base_url' in data and data['doubao_base_url']: + config.DOUBAO_BASE_URL = data['doubao_base_url'] + if 'kimi_api_key' in data and data['kimi_api_key']: + config.KIMI_API_KEY = data['kimi_api_key'] + if 'kimi_model' in data and data['kimi_model']: + config.KIMI_MODEL = data['kimi_model'] + if 'kimi_base_url' in data and data['kimi_base_url']: + config.KIMI_BASE_URL = data['kimi_base_url'] + if 'max_concurrent' in data: + v = int(data['max_concurrent']) + config.MAX_CONCURRENT_SECTIONS = max(1, min(v, 20)) + if 'content_volume' in data and data['content_volume'] in ('concise', 'standard', 'detailed', 'full'): + config.CONTENT_VOLUME = data['content_volume'] + + _settings.save(config) + return jsonify({'success': True}) + + +# ═══════════════════════════════════════════════════════════════════════════ +# 启动 +# ═══════════════════════════════════════════════════════════════════════════ + +if __name__ == '__main__': + init_db() + print('\n' + '=' * 60) + print(' BidPartner - AI Bid Writing Assistant') + print('=' * 60) + print(' URL: http://localhost:5000') + print(' Press Ctrl+C to quit\n') + app.run(host='0.0.0.0', port=5000, debug=False, threaded=True) diff --git a/bid_partner.spec b/bid_partner.spec new file mode 100644 index 0000000..6f4517e --- /dev/null +++ b/bid_partner.spec @@ -0,0 +1,118 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec for 标伙伴 · AI标书助手 +Build: pyinstaller bid_partner.spec + +知识库改用 SQLite + 纯 Python 向量存储,已不依赖 ChromaDB,打包更小。 +""" +import os +from PyInstaller.utils.hooks import collect_all, collect_data_files + +block_cipher = None + +# ── Collect complex packages ───────────────────────────────────────────────── +openai_datas, openai_bins, openai_hidden = collect_all('openai') +pydantic_datas, pydantic_bins, pydantic_hidden = collect_all('pydantic') + +# tiktoken data (BPE vocab files) +tiktoken_datas = collect_data_files('tiktoken') + +a = Analysis( + ['launcher.py'], + pathex=['.'], + binaries=openai_bins + pydantic_bins, + datas=[ + # ── App assets (read-only, go into _MEIPASS) ── + ('templates', 'templates'), + ('static', 'static'), + # ── Package data ── + *openai_datas, + *pydantic_datas, + *tiktoken_datas, + ], + hiddenimports=[ + # Flask / Werkzeug + 'flask', 'flask_cors', 'werkzeug', 'werkzeug.serving', + 'werkzeug.routing', 'werkzeug.middleware.proxy_fix', + 'jinja2', 'jinja2.ext', + # SQLite (stdlib, always present) + 'sqlite3', + # OpenAI + *openai_hidden, + # Pydantic + *pydantic_hidden, + # Document processing + 'PyPDF2', 'pypdf', 'pypdf.errors', + 'pdfminer', 'pdfminer.high_level', 'pdfminer.layout', + 'pdfminer.pdfpage', 'pdfminer.pdfinterp', 'pdfminer.converter', + 'docx', 'docx.oxml', 'docx.oxml.ns', 'docx.shared', + 'docx.enum', 'docx.enum.text', 'docx.enum.style', + 'python_docx', + # tiktoken + 'tiktoken', 'tiktoken.core', 'tiktoken.model', + 'tiktoken_ext', 'tiktoken_ext.openai_public', + # Network / encoding + 'requests', 'chardet', 'httpx', 'httpcore', + 'anyio', 'anyio.streams', 'anyio.streams.memory', + 'sniffio', 'certifi', + # Stdlib extras + 'importlib.metadata', 'importlib.resources', + 'pkg_resources', 'json', 'math', 'threading', + # Local project modules (explicitly include all) + 'config', 'app', + 'utils', 'utils.ai_client', 'utils.file_utils', + 'utils.prompts', 'utils.settings', 'utils.boq_parser', 'utils.bill_analysis', + 'modules', 'modules.parser', 'modules.generator', + 'modules.checker', 'modules.exporter', 'modules.knowledge', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # Heavy packages not used in this app + 'matplotlib', 'pandas', 'scipy', 'numpy', + 'IPython', 'jupyter', 'notebook', + 'PIL', 'Pillow', + 'cv2', 'torch', 'tensorflow', + 'pytest', 'unittest', + # ChromaDB 及其依赖(已移除,改用 SQLite 内置存储) + 'chromadb', 'hnswlib', 'posthog', 'pypika', + 'mmh3', 'overrides', 'monotonic', + 'sentence_transformers', 'onnxruntime', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='bid_partner', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, # no black console window — GUI launcher takes over + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='BidPartner', +) diff --git a/bill-worker.js b/bill-worker.js new file mode 100644 index 0000000..ee84bb9 --- /dev/null +++ b/bill-worker.js @@ -0,0 +1,672 @@ +/** + * bill-worker.js — PDF 清单解析调度器(Worker Thread) + * + * 架构(v3 — SharedArrayBuffer 零拷贝): + * Phase 1 — 并行文本提取 + * 将 PDF 数据写入 SharedArrayBuffer(一次分配,所有子线程共享读) + * 启动 N 个 page-worker,每个负责固定 20 页 + * + * Phase 2 — 清单页筛选 + 文本解析(纯正则,毫秒级) + * 汇总全部页面文本 → 关键字筛选清单页 → 多行合并 → 逐行解析 + */ +'use strict'; +const { parentPort } = require('worker_threads'); +const { Worker } = require('worker_threads'); +const path = require('path'); + +const PAGES_PER_CHUNK = 20; + +parentPort.on('message', async (msg) => { + if (msg.type !== 'parse') return; + const t0 = Date.now(); + try { + // 立即做一次干净的拷贝,确保拥有独立的 ArrayBuffer + const raw = msg.buffer; + const buf = Buffer.alloc(raw.byteLength); + Buffer.from(raw).copy(buf); + + if (buf.length === 0) { + parentPort.postMessage({ type: 'done', ok: false, error: '收到空 PDF 数据' }); + return; + } + + // ── 获取总页数 ── + const pdfjsModule = await import('pdfjs-dist/build/pdf.mjs'); + const pdfjsLib = pdfjsModule.default || pdfjsModule; + // 给 pdfjs 一份独立拷贝(pdfjs 内部可能 detach buffer) + const pdfData = new Uint8Array(buf.length); + buf.copy(Buffer.from(pdfData.buffer)); + const pdf = await pdfjsLib.getDocument({ data: pdfData, isEvalSupported: false }).promise; + const totalPages = pdf.numPages; + + // ── 将 PDF 数据写入 SharedArrayBuffer(一次分配,所有子线程共享读)── + const sab = new SharedArrayBuffer(buf.length); + const sabView = new Uint8Array(sab); + buf.copy(Buffer.from(sabView.buffer)); // 从独立 buf 拷贝到共享内存 + + const workerCount = Math.ceil(totalPages / PAGES_PER_CHUNK); + console.log(`[BillWorker] PDF ${totalPages} 页, ${workerCount} 路并行 (SharedArrayBuffer ${(buf.length/1024/1024).toFixed(1)}MB)`); + + // Phase 1: 并行文本提取 + const pageTexts = await parallelExtract(sab, buf.length, totalPages, workerCount); + const t1 = Date.now(); + + const extractedCount = pageTexts.filter(t => t.length > 0).length; + console.log(`[BillWorker] Phase1 完成: ${t1 - t0}ms, ${extractedCount}/${totalPages} 页有文本`); + + // 扫描件判断 + const totalChars = pageTexts.reduce((s, t) => s + t.length, 0); + if (totalChars < 50) { + parentPort.postMessage({ type: 'done', ok: true, data: { scanned: true, reason: 'noText', totalPages } }); + return; + } + + // Phase 2: 筛选清单页(宽松策略 + 连续页补全) + const BILL_KW = ['项目编码', '项目名称', '工程量', '计量单位', '综合单价', '清单编码']; + const SEC_KW = ['分部分项', '分类分项', '措施项目', '其他项目', '工程量清单计价']; + // 第一轮:标记确定的清单页 + const billFlags = new Array(pageTexts.length).fill(false); + for (let i = 0; i < pageTexts.length; i++) { + const t = pageTexts[i]; + if (!t.trim()) continue; + const hHits = BILL_KW.filter(k => t.includes(k)).length; + const sHit = SEC_KW.some(k => t.includes(k)); + const hasCode = /\d{9}/.test(t); + // 放宽:有9位编码即可(不再要求同时命中表头关键字) + if (hHits >= 2 || sHit || hasCode) { + billFlags[i] = true; + } + } + // 第二轮:连续页补全 — 两个清单页之间的非空页也视为清单页(续页无表头) + // 但排除纯费用/税金页面(它们不含施工清单项) + const FEE_PAGE_KW = ['规费', '税金', '社会保险费', '住房公积金', '养老保险', + '工伤保险', '失业保险', '医疗保险', '教育费附加', '城市维护建设税']; + const firstBill = billFlags.indexOf(true); + const lastBill = billFlags.lastIndexOf(true); + if (firstBill >= 0 && lastBill > firstBill) { + for (let i = firstBill; i <= lastBill; i++) { + if (!billFlags[i] && pageTexts[i] && pageTexts[i].trim().length > 30) { + const t = pageTexts[i]; + const feeHits = FEE_PAGE_KW.filter(kw => t.includes(kw)).length; + // 命中 2+ 个费用关键字且没有9位工程编码 → 纯费用页,排除 + if (feeHits >= 2 && !/\d{9}/.test(t)) continue; + billFlags[i] = true; + } + } + } + const billTexts = []; + for (let i = 0; i < pageTexts.length; i++) { + if (billFlags[i]) billTexts.push(pageTexts[i]); + } + + if (!billTexts.length) { + parentPort.postMessage({ type: 'done', ok: true, data: { scanned: false, noBillPages: true, totalPages } }); + return; + } + + console.log(`[BillWorker] ${totalPages} 页 → ${billTexts.length} 页清单 (原始识别 ${billFlags.filter(f=>f).length - (lastBill - firstBill >= 0 ? 0 : 0)} / 补全后 ${billTexts.length})`); + + // Phase 3: 文本解析 + const merged = billTexts.join('\n'); + const parsed = parseBillText(merged); + const t2 = Date.now(); + console.log(`[BillWorker] Phase2+3: ${t2 - t1}ms, 总耗时: ${t2 - t0}ms`); + + parentPort.postMessage({ + type: 'done', ok: true, + data: { + scanned: false, + ...parsed, + _meta: { + method: 'local-parallel', + workers: workerCount, + billPages: billTexts.length, + totalPages, + extractMs: t1 - t0, + parseMs: t2 - t1, + totalMs: t2 - t0, + } + } + }); + } catch (err) { + console.error('[BillWorker] 错误:', err.message); + parentPort.postMessage({ type: 'done', ok: false, error: err.message }); + } +}); + +// ================================================================ +// Phase 1: 多 Worker 并行提取(SharedArrayBuffer 零拷贝) +// ================================================================ + +function parallelExtract(sab, dataLength, totalPages, workerCount) { + return new Promise((resolve) => { + const workerPath = path.join(__dirname, 'page-worker.js'); + const allPageTexts = new Array(totalPages).fill(''); + const workerStatus = new Array(workerCount).fill('pending'); // pending, done, failed + let resolved = false; + + const checkComplete = () => { + if (resolved) return; + const doneCount = workerStatus.filter(s => s === 'done' || s === 'failed').length; + if (doneCount >= workerCount) { + resolved = true; + // 检查是否有失败的worker,打印警告 + const failedCount = workerStatus.filter(s => s === 'failed').length; + if (failedCount > 0) { + console.warn(`[BillWorker] ${failedCount}/${workerCount} 个worker失败,可能导致部分页面无内容`); + } + resolve(allPageTexts); + } + }; + + for (let i = 0; i < workerCount; i++) { + const startPage = i * PAGES_PER_CHUNK + 1; + const endPage = Math.min((i + 1) * PAGES_PER_CHUNK, totalPages); + + // workerData 传 SharedArrayBuffer(跨线程共享,不会被清空) + const w = new Worker(workerPath, { + workerData: { sab, dataLength, startPage, endPage } + }); + + let workerDone = false; + + const markDone = (status) => { + if (workerDone) return; + workerDone = true; + workerStatus[i] = status; + checkComplete(); + }; + + w.on('message', (msg) => { + if (msg.ok && msg.results) { + for (const r of msg.results) { + allPageTexts[r.page - 1] = r.text; + } + markDone('done'); + } else if (!msg.ok) { + console.warn(`[BillWorker] page-worker[${startPage}-${endPage}] 失败: ${msg.error}`); + markDone('failed'); + } + }); + + w.on('error', (err) => { + console.warn(`[BillWorker] page-worker[${startPage}-${endPage}] 异常: ${err.message}`); + markDone('failed'); + }); + + w.on('exit', (code) => { + // exit 在 message 之后触发,但如果 worker 崩溃没发 message 则在这里兜底 + if (code !== 0 && !workerDone) { + console.warn(`[BillWorker] page-worker[${startPage}-${endPage}] 意外退出(code=${code})`); + markDone('failed'); + } else if (!workerDone) { + markDone('done'); + } + }); + } + + if (workerCount <= 0) { + resolved = true; + resolve(allPageTexts); + } + }); +} + +// ================================================================ +// Phase 3: 清单文本解析(纯正则 + 字符串处理,毫秒级) +// ================================================================ + +function parseBillText(text) { + const rawLines = text.split(/\n/).map(l => { + let line = l.replace(/\t/g, ' ').trim(); + // 规范化带横杠的编码:如 "010-101-001-001" → "010101001001" + line = line.replace(/(\d{2,4})[-‐–](\d{2,4})[-‐–](\d{2,4})(?:[-‐–](\d{2,4}))?/g, + (m, a, b, c, d) => { + const combined = a + b + c + (d || ''); + return (combined.length >= 9 && combined.length <= 12) ? combined : m; + }); + return line; + }); + + // ── Step 1: 多行合并成逻辑行 ── + // pdfjs 按 Y 坐标分行,表格一行通常 = 一条文本行 + // 但有时 项目特征/名称 会折行,需要合并 + // + // 新逻辑行的起始标志(任一命中即切断): + // a) 序号模式:1.1.1.1.5 开头 + // b) 清单编码:9-12位数字 或 B+5-6位数字 开头 + // c) 中文大标题:一 二 三 ... 或 (一)(二)... + // d) 表头行内容(跳过) + // e) 纯数字序号 + 空格 + 编码(如 "5 500101004001") + + const ITEM_START = /^\d+(\.\d+)+\s/; // 1.1 或 1.1.1 等序号 + const CODE_INLINE = /(?:^|\s)(\d{9,12}|(? raw.startsWith(m + ' ') || raw.startsWith(m + '\u3000'))) return true; + return false; + } + + for (const raw of rawLines) { + if (!raw || PAGE_MARK.test(raw)) continue; + if (HEADER_RE.test(raw) || HEADER_KW.test(raw)) continue; + if (/^(元)|^款章节号|^备注$|^第\d+页/.test(raw)) continue; + + if (isNewLineTrigger(raw)) { + if (currentLine) logicLines.push(currentLine); + currentLine = raw; + } else if (CODE_INLINE.test(raw) && raw.length > 15) { + // 行内包含编码且够长(像是完整的表格行)→ 也开新行 + if (currentLine) logicLines.push(currentLine); + currentLine = raw; + } else { + // 续行(项目特征折行等短文本) + // 安全阀:已合并行过长时强制切断,防止整页吞并 + if (currentLine && currentLine.length > 300) { + logicLines.push(currentLine); + currentLine = raw; + } else { + currentLine = currentLine ? currentLine + ' ' + raw : raw; + } + } + } + if (currentLine) logicLines.push(currentLine); + + console.log(`[BillWorker] 合并后 ${logicLines.length} 条逻辑行(原始 ${rawLines.length} 行)`); + // 打印前5条逻辑行供调试 + for (let i = 0; i < Math.min(5, logicLines.length); i++) { + console.log(`[BillWorker] L${i}: ${logicLines[i].substring(0, 120)}`); + } + + const categories = []; + let curCat = null, curItem = null; + + // 编码匹配:支持行内任意位置的9-12位数字或B编码(排除 GB/DB 等标准号前缀) + const CODE_RE = /(? u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const UNIT_RE = new RegExp(`(?:^|\\s)(${unitEscaped.join('|')})(?=\\s|\\d|$)`); + const SKIP_RE = /合\s*计|小\s*计|本页小计|总\s*计|价税合计/; + + for (const line of logicLines) { + if (SKIP_RE.test(line)) continue; + + // 去掉行首的序号部分("1.1.1.1.5 " 或 "5 " 等纯序号前缀) + let stripped = line.replace(/^\d+(\.\d+)*\s+/, '').trim(); + if (!stripped) stripped = line.trim(); + if (!stripped) continue; + + const cm = stripped.match(CODE_RE); + if (cm) { + if (curItem && curCat) curCat.items.push(curItem); + if (!curCat) { curCat = { name: '未分类', items: [] }; categories.push(curCat); } + + const code = cm[1]; + let rest = stripped.substring(cm.index + cm[0].length).trim(); + let name = '', unit = '', quantity = '', spec = ''; + + const unitMatch = rest.match(UNIT_RE); + if (unitMatch) { + const ui = rest.indexOf(unitMatch[0]); + let rawName = rest.substring(0, ui).trim(); + unit = unitMatch[1]; + const afterUnit = rest.substring(ui + unitMatch[0].length).trim(); + const qm = afterUnit.match(/^([\d,.]+)/); + if (qm) { + quantity = qm[1]; + // 提取 quantity 之后的尾部文本,跳过纯数字字段(综合单价、合价等) + let tail = afterUnit.substring(qm.index + qm[0].length).trim(); + if (tail) { + const tailTokens = tail.split(/\s+/); + let si = 0; + while (si < tailTokens.length && /^[\d,.%\-]+$/.test(tailTokens[si])) si++; + const specTail = tailTokens.slice(si).join(' ').trim(); + if (specTail) spec = specTail; + } + } + // 分离 rawName 中的"项目名称"和内联"项目特征" + const ns = splitNameAndSpec(rawName); + name = ns.name; + if (ns.spec) spec = ns.spec + (spec ? ';' + spec : ''); + } else { + const tokens = rest.split(/\s+/).filter(t => t); + let foundUnitIdx = -1; + for (let ti = tokens.length - 1; ti >= 1; ti--) { + if (UNIT_SET.has(tokens[ti])) { foundUnitIdx = ti; break; } + } + if (foundUnitIdx >= 1) { + const rawNameStr = tokens.slice(0, foundUnitIdx).join(' '); + const ns = splitNameAndSpec(rawNameStr); + name = ns.name; + if (ns.spec) spec = ns.spec; + unit = tokens[foundUnitIdx]; + const afterTokens = tokens.slice(foundUnitIdx + 1); + if (afterTokens.length && /^[\d,.]+$/.test(afterTokens[0])) { + quantity = afterTokens[0]; + let si = 1; + while (si < afterTokens.length && /^[\d,.%\-]+$/.test(afterTokens[si])) si++; + const specTail = afterTokens.slice(si).join(' ').trim(); + if (specTail) spec = spec ? spec + ';' + specTail : specTail; + } + } else { + name = rest; + } + } + + name = name.replace(/\s+/g, '').trim(); + for (const u of UNIT_TOKENS) { + if (name.endsWith(u) && name.length > u.length) { + unit = unit || u; + name = name.substring(0, name.length - u.length); + break; + } + } + + curItem = { code, name, unit, quantity, spec }; + continue; + } + + // ── 回退:无标准编码但有 "名称 单位 数量" 结构 → 也视为清单项 ── + // 常见于措施项目、未编码的补充清单项 + if (!cm && stripped.length > 4) { + const uniMatch = stripped.match(UNIT_RE); + if (uniMatch) { + const ui = stripped.indexOf(uniMatch[0]); + const beforeUnit = stripped.substring(0, ui).trim(); + const afterUnit = stripped.substring(ui + uniMatch[0].length).trim(); + const hasQty = /^[\d,.]+/.test(afterUnit); + // 名称 2-50 字、含中文、有数量、不是分部标题 + if (beforeUnit.length >= 2 && beforeUnit.length <= 50 && hasQty + && /[\u4e00-\u9fff]/.test(beforeUnit)) { + if (curItem && curCat) curCat.items.push(curItem); + if (!curCat) { curCat = { name: '未分类', items: [] }; categories.push(curCat); } + const unit = uniMatch[1]; + const qm = afterUnit.match(/^([\d,.]+)/); + const quantity = qm ? qm[1] : ''; + const ns = splitNameAndSpec(beforeUnit); + const name = ns.name.replace(/\s+/g, '').trim(); + const spec = ns.spec || ''; + curItem = { code: '', name, unit, quantity, spec }; + continue; + } + } + } + + // 分部标题判断:不含编码、较短的文本、含工程关键字 + // 关键守卫:如果行里有计量单位,说明是清单项,不是标题 + if (stripped.length > 2 && stripped.length < 60 && !CODE_RE.test(stripped)) { + if (UNIT_RE.test(stripped) && /\d+\.?\d*\s*$/.test(stripped)) { + if (curItem) curItem.spec = curItem.spec ? curItem.spec + ';' + stripped : stripped; + continue; + } + if (isCatTitle(stripped) && !UNIT_RE.test(stripped) && !isFeeCatTitle(stripped)) { + if (curItem && curCat) { curCat.items.push(curItem); curItem = null; } + const cleanTitle = stripped.replace(/\s+(座|个|项|处|m|km|段|条)\s+\d+[\d.]*\s*$/, '').trim(); + curCat = { name: cleanTitle, items: [] }; + categories.push(curCat); + continue; + } + } + + if (/^[一二三四五六七八九十]+\s/.test(stripped) || /^([一二三四五六七八九十\d]+)/.test(stripped)) { + // 中文序号标题也需要排除费用类 + const cleanTitle = stripped.replace(/\s+(座|个|项|处)\s+\d+[\d.]*\s*$/, '').trim(); + if (isFeeCatTitle(cleanTitle)) { + // 费用类标题:跳过,不建分部(其下的行会作为续行处理) + continue; + } + if (curItem && curCat) { curCat.items.push(curItem); curItem = null; } + curCat = { name: cleanTitle, items: [] }; + categories.push(curCat); + continue; + } + + if (curItem && stripped.length > 1) { + curItem.spec = curItem.spec ? curItem.spec + ';' + stripped : stripped; + } + } + + if (curItem && curCat) curCat.items.push(curItem); + + // 过滤费用项:只保留需要写入技术标的施工清单项 + let feeFiltered = 0; + for (const cat of categories) { + if (cat.items) { + const before = cat.items.length; + cat.items = cat.items.filter(it => !isFeeItem(it.name)); + feeFiltered += before - cat.items.length; + } + } + if (feeFiltered > 0) console.log(`[BillWorker] 费用项过滤: 移除 ${feeFiltered} 项`); + + // ========== 按项目名称合并(核心去重,大幅减少清单项数量)========== + // 规则:同一分部内,name 相同的清单项合并为一条 + // - code: 保留第一个非空编码 + // - unit: 保留第一个非空单位 + // - quantity: 尝试数值求和,否则用分号拼接 + // - spec: 去重后用分号拼接(截断过长的) + let totalBeforeMerge = 0, totalAfterMerge = 0; + for (const cat of categories) { + if (!cat.items || !cat.items.length) continue; + totalBeforeMerge += cat.items.length; + + const nameMap = new Map(); // name → merged item + for (const item of cat.items) { + const key = (item.name || '').replace(/\s+/g, '').trim(); + if (!key) continue; + + if (!nameMap.has(key)) { + nameMap.set(key, { + code: item.code || '', + name: item.name, + unit: item.unit || '', + quantity: item.quantity || '', + spec: item.spec || '', + _count: 1, + _quantities: item.quantity ? [item.quantity] : [], + _specs: item.spec ? [item.spec] : [], + }); + } else { + const m = nameMap.get(key); + m._count++; + // code: 取第一个非空的 + if (!m.code && item.code) m.code = item.code; + // unit: 取第一个非空的 + if (!m.unit && item.unit) m.unit = item.unit; + // quantity: 收集所有 + if (item.quantity) m._quantities.push(item.quantity); + // spec: 收集不重复的 + if (item.spec && !m._specs.includes(item.spec)) { + m._specs.push(item.spec); + } + } + } + + // 后处理:合成最终字段 + const merged = []; + for (const [, m] of nameMap) { + // quantity: 尝试数值求和 + if (m._quantities.length > 1) { + const nums = m._quantities.map(q => parseFloat(q.replace(/,/g, ''))); + if (nums.every(n => !isNaN(n))) { + const sum = nums.reduce((a, b) => a + b, 0); + m.quantity = sum % 1 === 0 ? String(sum) : sum.toFixed(2); + } else { + m.quantity = m._quantities.join('; '); + } + } else if (m._quantities.length === 1) { + m.quantity = m._quantities[0]; + } + // spec: 拼接去重后的 spec,每条最多120字 + if (m._specs.length > 0) { + const trimmed = m._specs.map(s => s.length > 120 ? s.substring(0, 120) + '...' : s); + m.spec = trimmed.join('; '); + // 总 spec 上限 300 字 + if (m.spec.length > 300) m.spec = m.spec.substring(0, 300) + '...'; + } + // 清理临时字段 + delete m._count; delete m._quantities; delete m._specs; + merged.push(m); + } + cat.items = merged; + totalAfterMerge += merged.length; + } + + const mergedCount = totalBeforeMerge - totalAfterMerge; + if (mergedCount > 0) { + console.log(`[BillWorker] 按名称合并: ${totalBeforeMerge} → ${totalAfterMerge} 项(合并 ${mergedCount} 个重复项)`); + } + + const valid = categories.filter(c => c.items && c.items.length > 0); + const totalItems = valid.reduce((s, c) => s + c.items.length, 0); + const withSpec = valid.reduce((s, c) => s + c.items.filter(it => it.spec).length, 0); + const withCode = valid.reduce((s, c) => s + c.items.filter(it => it.code).length, 0); + console.log(`[BillWorker] 最终结果: ${valid.length} 分部, ${totalItems} 清单项 (${withCode} 有编码, ${withSpec} 有spec)`); + // 打印前 3 个 item 供调试 + let debugCount = 0; + for (const cat of valid) { + for (const it of cat.items) { + if (debugCount < 3) { + console.log(`[BillWorker] 样例: [${it.code}] ${it.name} | ${it.unit} | qty=${it.quantity} | spec=${(it.spec||'').substring(0, 80)}`); + debugCount++; + } + } + } + + return { + project_summary: { remark: `本地解析:${valid.length} 个分部,${totalItems} 个清单项(合并前 ${totalBeforeMerge} 项)` }, + categories: valid, + }; +} + +/** + * 判断清单项是否为"费用项"(非施工内容,不写入技术标) + * 如:安全文明措施费、规费、税金、暂列金额等 + */ +function isFeeItem(name) { + if (!name) return false; + const n = name.replace(/\s+/g, ''); + + // ── 1. 精确匹配 ── + const EXACT = [ + '规费', '税金', '利润', '增值税', '暂列金额', '暂估价', '计日工', + '总承包服务费', '企业管理费', '甲供材料保管费', '价税合计', + ]; + if (EXACT.includes(n)) return true; + + // ── 2. 包含匹配:措施费/规费/保险/行政类 ── + const FEE_KW = [ + '安全文明', '文明施工费', '环境保护费', '临时设施费', + '夜间施工增加费', '夜间施工费', + '冬雨季施工增加费', '冬雨季施工费', + '二次搬运费', '大型机械设备进出场', '大型机械进出场', + '施工排水降水', '排水降水费', + '已完工程及设备保护', '已完工程保护费', + '工程排污费', '社会保障费', '住房公积金', + '工伤保险', '劳动保险', '意外伤害保险', '建筑工程保险', + '城市维护建设税', '城市建设维护税', + '教育费附加', '地方教育附加', + '材料暂估', '专业工程暂估', + '超高施工增加费', '安全防护费', + '措施项目费', '其他项目费', '不可竞争费', + ]; + for (const kw of FEE_KW) { + if (n.includes(kw)) return true; + } + + return false; +} + +/** + * 将 rawName 中的"项目名称"与内联"项目特征描述"分离 + * 例: "土方开挖 1.土壤类别:普通土" → { name: "土方开挖", spec: "1.土壤类别:普通土" } + */ +function splitNameAndSpec(rawName) { + if (!rawName) return { name: '', spec: '' }; + // Pattern 1: 数字+点+中文(如 "1.土壤类别" "2、强度等级") + const m = rawName.match(/\d+[.、.)\uFF09]\s*[\u4e00-\u9fff]/); + if (m && m.index > 0) { + return { + name: rawName.substring(0, m.index).trim(), + spec: rawName.substring(m.index).trim() + }; + } + // Pattern 2: 特征关键字+冒号(如 "材质:" "规格:") + const SPEC_KW_RE = /(材质|规格|型号|品牌|颜色|尺寸|厚度|直径|管径|强度|等级|类别|类型|做法|要求|标准|内容|工作内容|土壤|含量|配合比|工艺|方式|形式|范围|部位|位置|高度|宽度|长度|深度|坡度|截面|跨度|运距|开挖|回填|混凝土|钢筋|压实)[::]/; + const kw = rawName.match(SPEC_KW_RE); + if (kw && kw.index > 0) { + return { + name: rawName.substring(0, kw.index).trim(), + spec: rawName.substring(kw.index).trim() + }; + } + // Pattern 3: 括号开头的特征描述 "(1)" "(1)" + const paren = rawName.match(/[((]\d+[))]/); + if (paren && paren.index > 0) { + return { + name: rawName.substring(0, paren.index).trim(), + spec: rawName.substring(paren.index).trim() + }; + } + return { name: rawName, spec: '' }; +} + +function isCatTitle(text) { + const KW = [ + '土建','建筑','结构','装饰','装修','安装','给排水','暖通','空调','通风', + '电气','强电','弱电','消防','智能化','幕墙','门窗','园林','绿化','景观', + '市政','道路','桥梁','管网','基础','地基','桩基','主体','屋面','防水', + '保温','钢结构','排水','给水','照明','动力','防雷','电梯','人防','室外', + '附属','分部','工程','措施','清单','土石方','混凝土','砌筑','模板','脚手架', + '水利','河道','管道','阀门','设备','仪表','自动化','通信','网络', + '拆除','外墙','内墙','楼地面','天棚','吊顶','栏杆','屋顶','涂料','抹灰', + '廊道','阀门井','蓄水池','泵站','供水','引水','水源','渠道','闸门', + '围栏','警示','检修','管线','配电','水池','水塔','取水','净水', + ]; + return KW.some(k => text.includes(k)); +} + +/** + * 判断分部标题是否为"费用类"(不应创建分部分类) + * 如:规费、税金、措施项目费、其他项目费 等非施工类分部 + */ +function isFeeCatTitle(text) { + if (!text) return false; + const t = text.replace(/\s+/g, ''); + // 精确匹配整个标题 + const EXACT = [ + '规费', '税金', '利润', '增值税', '暂列金额', '暂估价', '计日工', + '总承包服务费', '企业管理费', '价税合计', + '措施项目费', '其他项目费', '不可竞争费', + ]; + if (EXACT.includes(t)) return true; + // 包含匹配 + const FEE_CAT_KW = [ + '措施项目费', '其他项目费', '不可竞争费', + '规费汇总', '税金汇总', '费率', '费用汇总', '费用合计', + '暂列金额', '暂估价', '计日工', '总承包服务费', + '安全文明施工费', '社会保障费', '住房公积金', + '工伤保险', '教育费附加', '城市维护建设税', + ]; + for (const kw of FEE_CAT_KW) { + if (t.includes(kw)) return true; + } + return false; +} diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..2f54fae --- /dev/null +++ b/build.bat @@ -0,0 +1,95 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal + +echo ============================================================ +echo BidPartner - Build Desktop EXE +echo ============================================================ +echo. + +:: ── 1. Check Python ──────────────────────────────────────────────────────── +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Python not found. Please install Python 3.9+. + pause & exit /b 1 +) + +:: ── 2. Install / upgrade PyInstaller ─────────────────────────────────────── +echo [Step 1/4] Installing PyInstaller... +pip install --quiet --upgrade pyinstaller +if errorlevel 1 ( + echo [ERROR] Failed to install PyInstaller. + pause & exit /b 1 +) + +:: ── 3. Install project dependencies (if not already installed) ───────────── +echo [Step 2/4] Checking dependencies... +pip install --quiet -r requirements.txt +if errorlevel 1 ( + echo [ERROR] Failed to install dependencies. + pause & exit /b 1 +) + +:: ── 4. Sanitize settings.json - REMOVE API KEYS before build ─────────────── +echo [Step 3/4] Sanitizing settings (removing API keys from build)... +if exist "data\settings.json" ( + :: Back up real settings + copy /y "data\settings.json" "data\settings.json.bak" >nul +) +:: Write a clean settings file with no real keys +( + echo { + echo "model_provider": "deepseek", + echo "qwen_api_key": "sk-your-qwen-key", + echo "qwen_model": "qwen-max", + echo "openai_api_key": "sk-your-openai-key", + echo "openai_model": "gpt-4o", + echo "deepseek_api_key": "sk-your-deepseek-key", + echo "deepseek_model": "deepseek-chat", + echo "max_concurrent": 5, + echo "content_volume": "standard" + echo } +) > "data\settings_clean.tmp" + +:: ── 5. Build ──────────────────────────────────────────────────────────────── +echo [Step 4/4] Building EXE with PyInstaller... +echo (This may take 3-10 minutes on first run) +echo. + +:: Clean previous build artifacts +if exist "build" rd /s /q "build" >nul 2>&1 +if exist "dist\BidPartner" rd /s /q "dist\BidPartner" >nul 2>&1 + +pyinstaller bid_partner.spec --noconfirm +set BUILD_RESULT=%errorlevel% + +:: ── Restore real settings ─────────────────────────────────────────────────── +if exist "data\settings.json.bak" ( + copy /y "data\settings.json.bak" "data\settings.json" >nul + del /f /q "data\settings.json.bak" >nul 2>&1 +) +del /f /q "data\settings_clean.tmp" >nul 2>&1 + +if %BUILD_RESULT% neq 0 ( + echo. + echo [ERROR] PyInstaller build failed. See output above for details. + pause & exit /b 1 +) + +:: ── 6. Result ─────────────────────────────────────────────────────────────── +echo. +echo ============================================================ +echo Build SUCCESSFUL! +echo Output: dist\BidPartner\bid_partner.exe +echo ============================================================ +echo. +echo The 'dist\BidPartner' folder is your distributable package. +echo Users only need this folder - no Python installation required. +echo Each user must set their own API key in the app settings. +echo. + +:: Open the output folder +explorer "dist\BidPartner" >nul 2>&1 + +endlocal +pause diff --git a/config.py b/config.py new file mode 100644 index 0000000..98d8a59 --- /dev/null +++ b/config.py @@ -0,0 +1,72 @@ +import os +import sys + +# When running as a PyInstaller bundle: +# sys._MEIPASS → read-only bundle dir (templates, static, prompts) +# sys.executable dir → writable dir next to the .exe (data, settings, db) +if getattr(sys, 'frozen', False): + _BUNDLE_DIR = sys._MEIPASS # bundled app files + BASE_DIR = os.path.dirname(sys.executable) # writable runtime dir +else: + _BUNDLE_DIR = os.path.dirname(os.path.abspath(__file__)) + BASE_DIR = _BUNDLE_DIR + +DATA_DIR = os.path.join(BASE_DIR, 'data') +UPLOAD_DIR = os.path.join(DATA_DIR, 'uploads') +EXPORT_DIR = os.path.join(DATA_DIR, 'exports') +KNOWLEDGE_DIR= os.path.join(DATA_DIR, 'knowledge') +DB_PATH = os.path.join(DATA_DIR, 'projects.db') +CHROMA_DIR = os.path.join(DATA_DIR, 'chroma') +PROMPTS_DIR = os.path.join(_BUNDLE_DIR, 'prompts') + +# ==================== AI 模型配置 ==================== +# 模型选择:'openai' | 'qwen' | 'deepseek' | 'ollama' +MODEL_PROVIDER = os.environ.get('MODEL_PROVIDER', 'qwen') + +# OpenAI +OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', 'sk-your-openai-key') +OPENAI_MODEL = os.environ.get('OPENAI_MODEL', 'gpt-4.1') +OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.openai.com/v1') + +# 阿里云通义千问 +QWEN_API_KEY = os.environ.get('QWEN_API_KEY', 'sk-your-qwen-key') +QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen-max') +QWEN_BASE_URL = os.environ.get('QWEN_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1') + +# DeepSeek +DEEPSEEK_API_KEY = os.environ.get('DEEPSEEK_API_KEY', 'sk-your-deepseek-key') +DEEPSEEK_MODEL = os.environ.get('DEEPSEEK_MODEL', 'deepseek-chat') +DEEPSEEK_BASE_URL = os.environ.get('DEEPSEEK_BASE_URL', 'https://api.deepseek.com/v1') + +# Ollama 本地(OpenAI 兼容接口) +OLLAMA_BASE_URL = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1') +OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'qwen3:8b') + +# 豆包 / 火山引擎(字节跳动,OpenAI 兼容接口) +DOUBAO_API_KEY = os.environ.get('DOUBAO_API_KEY', 'sk-your-doubao-key') +DOUBAO_MODEL = os.environ.get('DOUBAO_MODEL', 'doubao-1-5-pro-32k') +DOUBAO_BASE_URL = os.environ.get('DOUBAO_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3') + +# Kimi / Moonshot AI(OpenAI 兼容接口,支持 Embedding) +KIMI_API_KEY = os.environ.get('KIMI_API_KEY', 'sk-your-kimi-key') +KIMI_MODEL = os.environ.get('KIMI_MODEL', 'moonshot-v1-32k') +KIMI_BASE_URL = os.environ.get('KIMI_BASE_URL', 'https://api.moonshot.cn/v1') + +# Embedding 模型 +OPENAI_EMBEDDING_MODEL = 'text-embedding-3-small' +QWEN_EMBEDDING_MODEL = 'text-embedding-v3' +KIMI_EMBEDDING_MODEL = 'moonshot-v1-embedding' + +# ==================== 应用配置 ==================== +MAX_FILE_SIZE_MB = 50 +ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx'} +SECRET_KEY = 'bidhuo-partner-secret-2024' + +# ==================== 生成配置 ==================== +MAX_RETRIES = 3 +REQUEST_TIMEOUT = 180 +CHUNK_SIZE = 2000 # 知识库文本分块大小(字符数) +CHUNK_OVERLAP = 200 # 分块重叠大小 +TOP_K_KNOWLEDGE = 3 # 知识库检索数量 +MAX_CONCURRENT_SECTIONS = int(os.environ.get('MAX_CONCURRENT_SECTIONS', '5')) # 并发生成章节数 +CONTENT_VOLUME = os.environ.get('CONTENT_VOLUME', 'standard') # 篇幅档位: concise / standard / detailed / full diff --git a/data/projects.db b/data/projects.db new file mode 100644 index 0000000..5658972 Binary files /dev/null and b/data/projects.db differ diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 0000000..090f293 --- /dev/null +++ b/data/settings.json @@ -0,0 +1,22 @@ +{ + "model_provider": "qwen", + "qwen_api_key": "sk-999173b3ca7f425a97cc4b12a2d3575f", + "qwen_model": "qwen-long", + "qwen_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "openai_api_key": "sk-your-openai-key", + "openai_model": "gpt-4.1", + "openai_base_url": "https://api.openai.com/v1", + "deepseek_api_key": "sk-your-deepseek-key", + "deepseek_model": "deepseek-chat", + "deepseek_base_url": "https://api.deepseek.com/v1", + "ollama_base_url": "http://localhost:11434/v1", + "ollama_model": "qwen3:8b", + "doubao_api_key": "sk-your-doubao-key", + "doubao_model": "doubao-1-5-pro-32k", + "doubao_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "kimi_api_key": "sk-your-kimi-key", + "kimi_model": "moonshot-v1-32k", + "kimi_base_url": "https://api.moonshot.cn/v1", + "max_concurrent": 10, + "content_volume": "full" +} \ No newline at end of file diff --git a/data/uploads/1_boq_工程量清单1.pdf b/data/uploads/1_boq_工程量清单1.pdf new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/data/uploads/1_boq_工程量清单1.pdf differ diff --git a/data/uploads/1_招标文件正文1.pdf b/data/uploads/1_招标文件正文1.pdf new file mode 100644 index 0000000..136700e Binary files /dev/null and b/data/uploads/1_招标文件正文1.pdf differ diff --git a/data/uploads/2_boq_工程量清单1.pdf b/data/uploads/2_boq_工程量清单1.pdf new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/data/uploads/2_boq_工程量清单1.pdf differ diff --git a/data/uploads/2_招标文件正文1.pdf b/data/uploads/2_招标文件正文1.pdf new file mode 100644 index 0000000..136700e Binary files /dev/null and b/data/uploads/2_招标文件正文1.pdf differ diff --git a/data/uploads/3_boq_工程量清单1.pdf b/data/uploads/3_boq_工程量清单1.pdf new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/data/uploads/3_boq_工程量清单1.pdf differ diff --git a/data/uploads/3_招标文件正文1.pdf b/data/uploads/3_招标文件正文1.pdf new file mode 100644 index 0000000..136700e Binary files /dev/null and b/data/uploads/3_招标文件正文1.pdf differ diff --git a/data/uploads/4_boq_工程量清单1.pdf b/data/uploads/4_boq_工程量清单1.pdf new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/data/uploads/4_boq_工程量清单1.pdf differ diff --git a/data/uploads/4_招标文件正文1.pdf b/data/uploads/4_招标文件正文1.pdf new file mode 100644 index 0000000..136700e Binary files /dev/null and b/data/uploads/4_招标文件正文1.pdf differ diff --git a/data/uploads/5_boq_工程量清单1.pdf b/data/uploads/5_boq_工程量清单1.pdf new file mode 100644 index 0000000..e129e5a Binary files /dev/null and b/data/uploads/5_boq_工程量清单1.pdf differ diff --git a/data/uploads/5_招标文件正文1.pdf b/data/uploads/5_招标文件正文1.pdf new file mode 100644 index 0000000..136700e Binary files /dev/null and b/data/uploads/5_招标文件正文1.pdf differ diff --git a/data/uploads/6_boq_工程量清单.pdf b/data/uploads/6_boq_工程量清单.pdf new file mode 100644 index 0000000..24c7709 Binary files /dev/null and b/data/uploads/6_boq_工程量清单.pdf differ diff --git a/data/uploads/6_招标文件正文.pdf b/data/uploads/6_招标文件正文.pdf new file mode 100644 index 0000000..30df632 Binary files /dev/null and b/data/uploads/6_招标文件正文.pdf differ diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..ded7331 --- /dev/null +++ b/launcher.py @@ -0,0 +1,172 @@ +""" +标伙伴 · AI标书助手 — 桌面启动器 +运行此文件 (或打包后的 bid_partner.exe) 即可自动启动本地服务并打开浏览器。 +""" +import os +import sys +import socket +import threading +import time +import webbrowser +import urllib.request +import logging + + +# ── 找可用端口 ────────────────────────────────────────────────────────────── +def _find_free_port(start: int = 5000, attempts: int = 20) -> int: + for port in range(start, start + attempts): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(('127.0.0.1', port)) + return port + except OSError: + continue + return start # 最坏情况:直接用 5000,让 Flask 报错 + + +PORT = _find_free_port() + + +# ── 日志 ──────────────────────────────────────────────────────────────────── +def _setup_logging(): + if getattr(sys, 'frozen', False): + log_dir = os.path.dirname(sys.executable) + else: + log_dir = os.path.dirname(os.path.abspath(__file__)) + log_path = os.path.join(log_dir, 'bid_partner.log') + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', + handlers=[logging.FileHandler(log_path, encoding='utf-8', mode='a')], + ) + + +# ── 启动 Flask 服务 ───────────────────────────────────────────────────────── +def _start_server(): + try: + import app as flask_app + flask_app.init_db() + flask_app.app.run( + host='127.0.0.1', + port=PORT, + debug=False, + threaded=True, + use_reloader=False, + ) + except Exception as e: + logging.getLogger('launcher').error(f'服务启动失败: {e}', exc_info=True) + + +# ── 等待服务就绪 ───────────────────────────────────────────────────────────── +def _wait_for_server(timeout: int = 60) -> bool: + url = f'http://127.0.0.1:{PORT}' + deadline = time.time() + timeout + while time.time() < deadline: + try: + urllib.request.urlopen(url, timeout=1) + return True + except Exception: + time.sleep(0.4) + return False + + +# ── 主界面 (tkinter) ───────────────────────────────────────────────────────── +def _run_gui(): + import tkinter as tk + from tkinter import ttk, font as tkfont + + URL = f'http://127.0.0.1:{PORT}' + + root = tk.Tk() + root.title('标伙伴 · AI标书助手') + root.geometry('400x220') + root.resizable(False, False) + root.configure(bg='#f5f5f5') + + # ── 标题 ── + title_font = tkfont.Font(family='微软雅黑', size=14, weight='bold') + tk.Label(root, text='标伙伴 · AI 标书助手', font=title_font, + bg='#f5f5f5', fg='#1a1a2e').pack(pady=(22, 4)) + + # ── 状态行 ── + status_var = tk.StringVar(value='正在启动服务,请稍候…') + status_lbl = tk.Label(root, textvariable=status_var, + font=('微软雅黑', 10), bg='#f5f5f5', fg='#555') + status_lbl.pack(pady=4) + + # ── URL 链接 ── + url_lbl = tk.Label(root, text='', font=('Consolas', 10), + bg='#f5f5f5', fg='#1a73e8', cursor='hand2') + url_lbl.pack(pady=2) + url_lbl.bind('', lambda _: webbrowser.open(URL)) + + # ── 按钮区 ── + btn_frame = tk.Frame(root, bg='#f5f5f5') + btn_frame.pack(pady=18) + + open_btn = ttk.Button(btn_frame, text='打开浏览器', + command=lambda: webbrowser.open(URL), + state='disabled', width=14) + open_btn.pack(side='left', padx=8) + + quit_btn = ttk.Button(btn_frame, text='退出程序', + command=root.destroy, width=10) + quit_btn.pack(side='left', padx=8) + + # ── 版本信息 ── + tk.Label(root, text='单机版 · 本地运行 · 数据不上传', + font=('微软雅黑', 8), bg='#f5f5f5', fg='#aaa').pack(pady=(0, 10)) + + # ── 后台轮询,服务就绪后更新 UI ── + def _on_ready(): + status_var.set('服务已就绪 ✓') + status_lbl.config(fg='#2e7d32') + url_lbl.config(text=URL) + open_btn.config(state='normal') + webbrowser.open(URL) + + def _on_timeout(): + status_var.set('启动超时,请查看 bid_partner.log') + status_lbl.config(fg='#c62828') + + def _check(): + if _wait_for_server(): + root.after(0, _on_ready) + else: + root.after(0, _on_timeout) + + threading.Thread(target=_check, daemon=True).start() + root.mainloop() + + +# ── 无图形模式(仅控制台) ──────────────────────────────────────────────────── +def _run_headless(): + print(f'[标伙伴] Starting server on port {PORT} ...') + if _wait_for_server(): + print(f'[标伙伴] Ready → http://127.0.0.1:{PORT}') + webbrowser.open(f'http://127.0.0.1:{PORT}') + # 阻塞,直到用户 Ctrl+C + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print('[标伙伴] Shutting down.') + else: + print('[标伙伴] Server did not start within 60 s. Check bid_partner.log.') + + +# ── 入口 ───────────────────────────────────────────────────────────────────── +def main(): + _setup_logging() + + server_thread = threading.Thread(target=_start_server, daemon=True) + server_thread.start() + + try: + _run_gui() + except Exception: + _run_headless() + + +if __name__ == '__main__': + main() diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1 @@ + diff --git a/modules/checker.py b/modules/checker.py new file mode 100644 index 0000000..8292fc9 --- /dev/null +++ b/modules/checker.py @@ -0,0 +1,98 @@ +""" +合规检查模块:检查生成的标书是否响应了招标关键要求 +""" +import json +import logging +import re +import sqlite3 + +from utils import ai_client + +logger = logging.getLogger(__name__) + +CHECK_PROMPT = """你是一位专业的投标文件技术审核专家。请对照以下【技术评分要求】,检查【标书技术内容】的覆盖情况,输出技术合规检查报告。 + +重要限制(必须遵守): +★ 本次检查范围仅限技术内容,包括:技术方案、实施能力、技术指标、质量保障、人员配置、技术创新等 +★ 严禁将商务评分、价格评分、资质评分、报价、合同条款、付款方式等商务内容纳入检查项 +★ 若技术评分要求中混有商务条款,直接忽略,不得作为检查项输出 + +【技术评分要求】 +{requirements} + +【标书技术内容(各章节摘要)】 +{content} + +请输出以下格式的 JSON,每个 item 均为技术评分项,不含任何商务内容: +{{ + "overall_score": 85, + "status": "良好", + "items": [ + {{ + "requirement": "技术评分要求描述", + "covered": true, + "note": "说明" + }} + ], + "missing_points": ["未覆盖的技术要点1", "未覆盖的技术要点2"], + "suggestions": ["技术内容改进建议1", "技术内容改进建议2"] +}} +""" + + +def check_compliance(db_path: str, project_id: int) -> dict: + """ + 执行合规检查,返回检查结果字典。 + """ + conn = sqlite3.connect(db_path) + try: + # 获取招标要求 + cur = conn.cursor() + cur.execute( + "SELECT summary, rating_requirements FROM tender_data WHERE project_id=?", + (project_id,) + ) + td = cur.fetchone() + if not td: + return {'error': '尚未解析招标文件'} + + # 只使用技术评分要求作为检查基准,排除 summary 中可能包含的商务内容 + requirements = (td[1] or '').strip() + if not requirements: + return {'error': '尚未提取技术评分要求,请先完成步骤一的招标文件解析'} + + # 收集已生成的章节内容(取前 500 字) + cur.execute( + "SELECT section_title, content FROM bid_sections WHERE project_id=? AND status='done' ORDER BY order_index", + (project_id,) + ) + rows = cur.fetchall() + if not rows: + return {'error': '尚未生成标书内容,请先生成'} + + content_parts = [] + for title, content in rows: + snippet = (content or '')[:500].replace('\n', ' ') + content_parts.append(f"【{title}】{snippet}") + content_str = '\n'.join(content_parts) + + # 调用 AI 检查 + prompt = CHECK_PROMPT.format(requirements=requirements[:3000], content=content_str[:6000]) + raw = ai_client.chat(prompt, temperature=0.2, max_tokens=2048) + + # 解析 JSON + raw = re.sub(r'```(?:json)?\s*', '', raw).replace('```', '').strip() + m = re.search(r'\{[\s\S]*\}', raw) + if m: + raw = m.group(0) + result = json.loads(raw) + return result + + except json.JSONDecodeError as e: + logger.error(f'合规检查结果解析失败: {e}') + return {'error': f'AI 返回格式异常: {e}', 'raw': raw} + except Exception as e: + logger.exception('合规检查失败') + return {'error': str(e)} + finally: + conn.close() diff --git a/modules/exporter.py b/modules/exporter.py new file mode 100644 index 0000000..bae1325 --- /dev/null +++ b/modules/exporter.py @@ -0,0 +1,407 @@ +""" +Word 文档导出模块 +""" +import os +import re +import sqlite3 +import logging +from datetime import datetime +from docx import Document +from docx.shared import Pt, Cm, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn + +import config + +logger = logging.getLogger(__name__) + +LEVEL_STYLES = { + 1: ('Heading 1', 16, True), + 2: ('Heading 2', 14, True), + 3: ('Heading 3', 13, False), + 4: ('Heading 4', 12, False), +} + + +def export_to_word(db_path: str, project_id: int) -> str: + """ + 生成 Word 文档并保存到 data/exports/,返回文件名。 + """ + conn = sqlite3.connect(db_path) + try: + # 获取项目信息 + cur = conn.cursor() + cur.execute("SELECT name FROM projects WHERE id=?", (project_id,)) + project = cur.fetchone() + if not project: + raise ValueError(f'项目 {project_id} 不存在') + project_name = project[0] + + # 获取标书大纲文本(用于标题页) + cur.execute("SELECT outline FROM tender_data WHERE project_id=?", (project_id,)) + td = cur.fetchone() + bid_title = project_name + '技术标书' + if td and td[0]: + first_line = td[0].strip().split('\n')[0].strip() + if first_line: + bid_title = first_line + + # 获取所有章节(按顺序) + cur.execute(''' + SELECT section_number, section_title, level, is_leaf, content, intro_content + FROM bid_sections + WHERE project_id=? + ORDER BY order_index + ''', (project_id,)) + sections = cur.fetchall() + + doc = _build_document(bid_title, sections) + + # 保存文件 + os.makedirs(config.EXPORT_DIR, exist_ok=True) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + safe_name = ''.join(c for c in project_name if c.isalnum() or c in '._- \u4e00-\u9fff') + filename = f'{safe_name}_{timestamp}.docx' + filepath = os.path.join(config.EXPORT_DIR, filename) + doc.save(filepath) + logger.info(f'导出完成: {filepath}') + return filename + + finally: + conn.close() + + +DISCLAIMER_TEXT = """\ +免责声明 + +本工具仅供学习交流免费使用,所生成的技术方案不可直接用于投标,请务必人工核对。本工具不会通过任何平台进行销售,请用户注意辨别真伪。在您开始使用本AI标书制作服务之前,请认真阅读并同意以下关键条款。一旦您继续使用,即表示您已充分理解并认可本提示的全部内容。 + +服务定位 +本工具为单机使用的AI标书辅助工具,旨在帮助您生成标书的参考素材。您需对最终自己编写的标书文件承担全部责任,包括审核、修改内容,确保其符合相关法律法规及项目要求。 + +准确性免责 +本人不对AI生成内容的完全准确性与完整性作任何保证。您有义务自行核实所有关键信息,并自行承担因使用本工具所引发的一切后果。 + +标书风险 +本工具所生成的素材文件仅作参考。若您使用(包括引用、修改或二次创作),需自行承担由此可能导致的废标、侵权等全部风险与责任,本人不承担任何相关责任。 + +责任限制 +任何情形下,本人均不对因使用本服务而造成的任何直接、间接或衍生损失(例如利润损失、业务中断、数据丢失等)承担法律责任。 + +其他事项 +本人保留随时修改或终止本服务的权利。本提示的解释及争议解决,均适用中华人民共和国法律。\ +""" + + +def _add_disclaimer_page(doc: Document) -> None: + """在文档开头插入免责声明页""" + # 标题 + title_para = doc.add_paragraph() + title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + title_run = title_para.add_run('免责声明') + title_run.font.size = Pt(16) + title_run.font.bold = True + title_run.font.name = '黑体' + title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体') + + doc.add_paragraph() + + # 正文各段(跳过第一行标题,已单独渲染) + body_lines = DISCLAIMER_TEXT.split('\n')[2:] # 跳过"免责声明"和空行 + for line in body_lines: + p = doc.add_paragraph() + stripped = line.strip() + # 小标题行(非空且后面没有缩进,即段落标题) + is_section_title = bool(stripped) and not line.startswith(' ') and not line.startswith('\u3000') + run = p.add_run(stripped if stripped else '') + if is_section_title and stripped: + run.font.bold = True + run.font.size = Pt(11) + else: + run.font.size = Pt(10.5) + run.font.name = 'Times New Roman' + run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体') + p.paragraph_format.space_after = Pt(4) + _set_line_spacing_15(p) + + doc.add_page_break() + + +def _build_document(bid_title: str, sections) -> Document: + doc = Document() + + # ── 页面设置 ───────────────────────────────────────────────────────── + section_obj = doc.sections[0] + section_obj.page_width = Cm(21) + section_obj.page_height = Cm(29.7) + section_obj.left_margin = Cm(3) + section_obj.right_margin = Cm(2.5) + section_obj.top_margin = Cm(2.5) + section_obj.bottom_margin = Cm(2.5) + + # ── 免责声明页(第一页)───────────────────────────────────────────── + _add_disclaimer_page(doc) + + # ── 标题页 ────────────────────────────────────────────────────────── + title_para = doc.add_paragraph() + title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + title_run = title_para.add_run(bid_title) + title_run.font.size = Pt(22) + title_run.font.bold = True + title_run.font.color.rgb = RGBColor(0x1a, 0x56, 0xdb) + title_run.font.name = '黑体' + title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体') + + doc.add_paragraph() + + date_para = doc.add_paragraph() + date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + date_run = date_para.add_run(datetime.now().strftime('%Y年%m月')) + date_run.font.size = Pt(14) + date_run.font.name = '宋体' + date_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体') + + doc.add_page_break() + + # ── 章节内容 ───────────────────────────────────────────────────────── + for row in sections: + _, title, level, is_leaf, content, intro = row + level = min(int(level), 4) + + # 添加标题 + heading_text = title + heading = doc.add_heading(level=level) + heading.clear() + run = heading.add_run(heading_text) + _, font_size, bold = LEVEL_STYLES.get(level, ('Heading 4', 12, False)) + run.font.size = Pt(font_size) + run.font.bold = bold + run.font.name = '黑体' if level <= 2 else '宋体' + run._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体' if level <= 2 else '宋体') + + # 章节引言(非叶节点) + if intro and intro.strip(): + _add_body_paragraphs(doc, intro) + + # 正文内容(叶节点) + if content and content.strip(): + _add_body_paragraphs(doc, content) + + return doc + + +def _set_line_spacing_15(paragraph): + """将段落设为 1.5 倍行距(Word 中的 WD_LINE_SPACING.MULTIPLE × 1.5)""" + from docx.oxml.ns import qn as _qn + pPr = paragraph._element.get_or_add_pPr() + spacing = pPr.find(_qn('w:spacing')) + if spacing is None: + spacing = OxmlElement('w:spacing') + pPr.append(spacing) + spacing.set(_qn('w:line'), '360') # 240 × 1.5 = 360 twips + spacing.set(_qn('w:lineRule'), 'auto') + + +# ── 图/表标记解析 ───────────────────────────────────────────────────────── + +_BLOCK_PATTERN = re.compile( + r'\[FIGURE:([^\]]+)\](.*?)\[/FIGURE\]' + r'|\[TABLE:([^\]]+)\](.*?)\[/TABLE\]', + re.DOTALL +) + + +def _split_content_blocks(text: str) -> list: + """ + 将章节正文拆分为有序内容块列表: + {'type': 'text', 'content': '...'} + {'type': 'figure', 'title': '...', 'content': '...'} + {'type': 'table', 'title': '...', 'content': '...'} + """ + blocks = [] + last = 0 + for m in _BLOCK_PATTERN.finditer(text): + if m.start() > last: + blocks.append({'type': 'text', 'content': text[last:m.start()]}) + if m.group(1) is not None: + blocks.append({'type': 'figure', + 'title': m.group(1).strip(), + 'content': m.group(2).strip()}) + else: + blocks.append({'type': 'table', + 'title': m.group(3).strip(), + 'content': m.group(4).strip()}) + last = m.end() + if last < len(text): + blocks.append({'type': 'text', 'content': text[last:]}) + return blocks + + +def _set_para_shading(para, hex_fill: str): + """为段落设置背景填充色""" + pPr = para._element.get_or_add_pPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:color'), 'auto') + shd.set(qn('w:fill'), hex_fill) + pPr.append(shd) + + +def _set_cell_bg(cell, hex_fill: str): + """为表格单元格设置背景色""" + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:color'), 'auto') + shd.set(qn('w:fill'), hex_fill) + tcPr.append(shd) + + +def _set_cell_padding(cell, pt_value: float): + """设置表格单元格四侧内边距(单位:磅)""" + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + tcMar = OxmlElement('w:tcMar') + val = str(int(pt_value * 20)) # pt → twips(1pt = 20 twips) + for side in ('top', 'left', 'bottom', 'right'): + node = OxmlElement(f'w:{side}') + node.set(qn('w:w'), val) + node.set(qn('w:type'), 'dxa') + tcMar.append(node) + tcPr.append(tcMar) + + +def _safe_set_eastasia(run, font_name: str): + """安全设置东亚字体,确保 rPr 已存在""" + _ = run.font.size # 触发 rPr 创建 + try: + run._element.rPr.rFonts.set(qn('w:eastAsia'), font_name) + except Exception: + pass + + +def _add_block_caption(doc: Document, prefix: str, title: str): + """添加图/表居中加粗标题行""" + cap = doc.add_paragraph() + cap.alignment = WD_ALIGN_PARAGRAPH.CENTER + cap.paragraph_format.space_before = Pt(8) + cap.paragraph_format.space_after = Pt(3) + run = cap.add_run(f'{prefix}:{title}') + run.font.bold = True + run.font.size = Pt(11) + run.font.name = 'Times New Roman' + _safe_set_eastasia(run, '黑体') + + +def _add_figure_block(doc: Document, title: str, content: str): + """ + 将图示内容渲染为带边框 + 背景色的文字图示框。 + 使用单格表格(Table Grid 样式)实现四周边框,比纯段落背景更专业。 + """ + _add_block_caption(doc, '图', title) + + lines = content.split('\n') + + # 单格表格:四周边框 + 淡蓝灰背景 + tbl = doc.add_table(rows=1, cols=1) + tbl.style = 'Table Grid' + cell = tbl.cell(0, 0) + _set_cell_bg(cell, 'EFF3FB') # 淡蓝灰背景 + _set_cell_padding(cell, 5) # 内边距 5pt + + for i, line in enumerate(lines): + if i == 0: + para = cell.paragraphs[0] + para.clear() + else: + para = cell.add_paragraph() + para.paragraph_format.space_before = Pt(0) + para.paragraph_format.space_after = Pt(1) + run = para.add_run(line if line else ' ') + run.font.size = Pt(9.5) + run.font.name = 'Courier New' + _safe_set_eastasia(run, '宋体') + + # 图示后空行 + sp = doc.add_paragraph() + sp.paragraph_format.space_after = Pt(8) + + +def _add_word_table(doc: Document, title: str, content: str): + """将 Markdown 表格解析并渲染为 Word 表格""" + # 解析 markdown 行,过滤掉分隔行(|---|) + raw_rows = [] + for line in content.strip().split('\n'): + line = line.strip() + if not line: + continue + if re.match(r'^\|[\s\-:| ]+\|$', line): + continue # 分隔行 + if line.startswith('|') and line.endswith('|'): + cells = [c.strip() for c in line[1:-1].split('|')] + raw_rows.append(cells) + + if not raw_rows: + # 没有解析到有效行时,降级为普通文本 + _add_block_caption(doc, '表', title) + _add_plain_text(doc, content) + return + + col_count = max(len(r) for r in raw_rows) + rows = [r + [''] * (col_count - len(r)) for r in raw_rows] + + _add_block_caption(doc, '表', title) + + table = doc.add_table(rows=len(rows), cols=col_count) + table.style = 'Table Grid' + + for i, row_data in enumerate(rows): + for j, cell_text in enumerate(row_data): + cell = table.cell(i, j) + para = cell.paragraphs[0] + para.clear() + para.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT + run = para.add_run(cell_text) + run.font.size = Pt(10) + run.font.bold = (i == 0) + run.font.name = 'Times New Roman' + _safe_set_eastasia(run, '宋体') + if i == 0: + _set_cell_bg(cell, 'D6E4F7') # 浅蓝表头 + + # 表格后空行 + sp = doc.add_paragraph() + sp.paragraph_format.space_after = Pt(6) + + +def _add_plain_text(doc: Document, text: str): + """添加普通文本段落(内部辅助)""" + for line in text.split('\n'): + line = line.strip() + if not line: + continue + p = doc.add_paragraph() + p.paragraph_format.first_line_indent = Pt(24) + p.paragraph_format.space_after = Pt(6) + _set_line_spacing_15(p) + run = p.add_run(line) + run.font.size = Pt(12) + run.font.name = 'Times New Roman' + _safe_set_eastasia(run, '宋体') + + +def _add_body_paragraphs(doc: Document, text: str): + """ + 将正文文本分段渲染,自动识别并处理图示 [FIGURE:...] 和表格 [TABLE:...] 标记。 + """ + for block in _split_content_blocks(text): + if block['type'] == 'figure': + _add_figure_block(doc, block['title'], block['content']) + elif block['type'] == 'table': + _add_word_table(doc, block['title'], block['content']) + else: + _add_plain_text(doc, block['content']) + + diff --git a/modules/generator.py b/modules/generator.py new file mode 100644 index 0000000..6a0e96e --- /dev/null +++ b/modules/generator.py @@ -0,0 +1,1018 @@ +""" +标书内容生成模块 +流程:生成大纲 → 解析章节树 → 并发生成内容 +""" +import re +import sqlite3 +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime + +import config +from utils import ai_client, prompts as P + +logger = logging.getLogger(__name__) + +BID_WRITING_SYSTEM = ( + '你是一位资深的工程投标文件撰写专家,擅长以执行方视角撰写技术方案正文。' + '撰写时必须遵守以下铁律:' + + '①【字数】用户规定的最低字数必须满足,但字数须由实质内容支撑,' + '不得用重复背景、堆砌承诺或复述要求来凑字数;' + + '②【自称】投标方自称统一用"我方",禁用"我们""我公司";' + + '③【禁止套话】禁用:综上所述、首先其次再次、我们深信、高度重视、全力以赴、' + '竭诚服务、不断优化、稳步推进、通过以上措施、我方将严格按照、我方承诺、' + '确保圆满完成、切实保障;' + + '④【禁止前导句】严禁:本章节对应……、本小节主要说明……、' + '以下将从……方面说明、针对招标方要求……、根据招标文件……我方将……——' + '开头直接写实质内容;' + + '⑤【禁止复述要求】招标文件给出的技术参数、工程量、服务数量、规范标准等均视为' + '已知条件,直接体现在方案中,禁止先复读要求再作答;' + '不用"满足招标方提出的XXX要求""针对招标文件第X条"等句式;' + + '⑥【禁止重申背景——最常见的废稿场景】' + '禁止在章节正文中出现项目名称、建设单位、建设地点、工程规模、合同工期等基本信息;' + '尤其严禁将招标文件中的具体工程量数字(如"X条渠道""X公里""X座建筑物""X台设备"等)' + '反复引入到各个章节开头作为背景铺垫——' + '这类数字只能在专门的"项目概况/项目背景"章节出现一次,' + '质量、安全、进度、技术方案、人员配置等专业章节一律直接展开专业内容;' + + '⑦【禁止虚构优越参数】严禁为了显示"超越"招标要求而捏造参数或数量:' + '招标文件要求多少就按多少写,不得无依据地写成"优于要求""高于标准";' + '如需体现竞争力,只能在工艺方法、管理措施、响应速度等可具体描述的维度展开,' + '不得在规格数量上自行拔高;' + + '⑧【实质可检验】每项措施须给出具体做法、操作步骤、管理节点或时间节点;' + '凡写数量、型号、吨位、强度、时限等量化内容,须能在招标文件或工程量清单摘要中找到依据,' + '无依据处不写具体数字与型号,改用"按设计要求""与工况及进度相匹配""符合相应规范等级"等完整中文概括表述,' + '不做空洞承诺;' + + '⑨【行文格式】纯文本,段落间空行分隔,列举用(1)(2)(3)编号,' + '不用markdown符号,不用连接词串联,不用"等"作结尾。' + + '⑩【禁止占位符】方案叙述中严禁半角或全角方括号形式的未完稿待填(如[型号][数量][数值][X][Y]等),' + '亦不得用「待填」「TBD」留白;语义须用通顺的陈述句一次写清。' + '若另有图示/表格专用输出规范要求使用约定标记,仅在该规范限定的标记内可使用方括号。' +) + +# 篇幅档位:key → (基础小节字数, 核心章节字数, 标签, 期望max_tokens) +VOLUME_PRESETS = { + 'concise': (1200, 2500, '精简版', 5000), + 'standard': (2000, 4000, '标准版', 8000), + 'detailed': (3000, 5500, '详细版', 12000), + 'full': (4000, 7000, '充实版', 16000), +} + +# 各模型提供商的 max_tokens 硬上限 +_PROVIDER_TOKEN_LIMITS = { + 'deepseek': 8192, + 'qwen': 8192, + 'openai': 16384, +} + + +def _get_word_count_spec(volume: str) -> str: + """根据篇幅档位返回嵌入提示词的字数要求段落""" + base, core, _, _ = VOLUME_PRESETS.get(volume, VOLUME_PRESETS['standard']) + return ( + f'- 字数硬性要求(必须达到,不达标将被退回重写):\n' + f' · 一般小节:不少于 {base} 字\n' + f' · 核心技术/重点评分章节:不少于 {core} 字\n' + f'- 内容必须充分展开,每个要点均需具体阐述,不得一笔带过\n' + f'- 宁多勿少,写满写透,篇幅不足是最严重的质量问题' + ) + + +def _get_max_tokens(volume: str) -> int: + """根据篇幅档位返回 AI 调用的 max_tokens,自动适配提供商上限""" + _, _, _, tokens = VOLUME_PRESETS.get(volume, VOLUME_PRESETS['standard']) + provider = getattr(config, 'MODEL_PROVIDER', 'openai') + limit = _PROVIDER_TOKEN_LIMITS.get(provider, 8192) + return min(tokens, limit) + + +def _get_min_chars(volume: str) -> int: + """触发续写的最低字数阈值(基础小节字数的 65%,略低于目标以多轮补足)""" + base, _, _, _ = VOLUME_PRESETS.get(volume, VOLUME_PRESETS['standard']) + return int(base * 0.65) + + +# 中文数字映射 +CN_NUM_MAP = { + '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, + '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, + '十一': 11, '十二': 12, '十三': 13, '十四': 14, '十五': 15, +} + + +# ─── 大纲生成 ───────────────────────────────────────────────────────────── + +def generate_outline(db_path: str, project_id: int) -> None: + """后台:生成标书大纲并存入 bid_sections""" + conn = sqlite3.connect(db_path) + try: + _set_project_status(conn, project_id, 'outline_generating') + + td = _get_tender_data(conn, project_id) + if not td: + raise ValueError('尚未解析招标文件,请先解析') + + summary = td['summary'] or '' + rating = td['rating_requirements'] or '' + + if rating: + prompt = P.get_outlines_with_rating_prompt(summary, rating) + else: + prompt = P.get_outlines_prompt(summary or td['raw_text'] or '') + + outline_text = ai_client.chat(prompt, temperature=0.5, max_tokens=4096) + + # 解析章节并自动重排序号,保存规范化后的大纲文本 + bid_title, sections, normalized_text = _parse_outline(outline_text) + _save_outline_text(conn, project_id, normalized_text) + _save_sections(conn, project_id, sections) + + _set_project_status(conn, project_id, 'outline_done') + logger.info(f'项目 {project_id} 大纲生成完成,共 {len(sections)} 节') + + except Exception as e: + logger.exception(f'大纲生成失败 project_id={project_id}') + _set_project_status(conn, project_id, 'outline_error', str(e)) + finally: + conn.close() + + +# ─── 章节内容生成 ────────────────────────────────────────────────────────── + +def generate_section(db_path: str, project_id: int, section_id: int, + anon_requirements: str = '', + enable_figure: bool = False, + enable_table: bool = False) -> None: + """后台:为指定 section 生成正文内容(单个章节入口,自行读取上下文)""" + conn = sqlite3.connect(db_path) + try: + section = _get_section(conn, section_id) + if not section: + raise ValueError(f'Section {section_id} 不存在') + + td = _get_tender_data(conn, project_id) + outline_text = _get_outline_text(conn, project_id) + if not outline_text.strip(): + raise ValueError('当前项目尚无可用大纲,请先保存或生成大纲') + summary = (td or {}).get('summary', '') + boq_summary = (td or {}).get('boq_summary', '') + conn.close() + conn = None + + tender_kind = (td or {}).get('tender_kind', 'engineering') or 'engineering' + outline_head = outline_text.strip().splitlines()[0][:50] if outline_text.strip() else '' + logger.info( + f'章节生成读取大纲 project_id={project_id}, section_id={section_id}, ' + f'outline_len={len(outline_text)}, outline_head="{outline_head}"' + ) + _generate_one(db_path, section, summary, outline_text, + anon_requirements, enable_figure, enable_table, + boq_summary, tender_kind) + + except Exception as e: + logger.exception(f'章节生成失败 section_id={section_id}') + _update_section_status_safe(db_path, section_id, 'error', str(e)) + finally: + if conn: + conn.close() + + +MAX_CONTINUE_ROUNDS = 5 +# 单次续写目标字数上限:与 DeepSeek/Qwen 8192 max_tokens 下的实际中文产出量匹配,略保守更易写满 +_CONTINUE_CHUNK_CAP = 2800 +_CONTINUE_TAIL_CHARS = 2200 + + +def _auto_continue(content: str, min_chars: int, max_tok: int, title: str, + system: str = BID_WRITING_SYSTEM) -> str: + """ + 自动续写:当首次生成的内容字数不足时,发起独立的续写调用。 + 不传入完整的原始 prompt(太长会挤占输出空间),而是只提供 + 已有内容的末尾部分作为上下文,让 AI 集中精力续写。 + """ + for round_i in range(MAX_CONTINUE_ROUNDS): + if len(content) >= min_chars: + break + + remaining = min_chars - len(content) + if remaining <= 200: + break + + # 本轮只要求「差额」的一部分,多轮叠加更易达到总目标 + chunk_goal = min(remaining, _CONTINUE_CHUNK_CAP) + + tail = ( + content[-_CONTINUE_TAIL_CHARS:] + if len(content) > _CONTINUE_TAIL_CHARS + else content + ) + + cont_prompt = ( + f'以下是投标文件「{title}」小节已撰写的部分内容(末尾段落):\n\n' + f'{tail}\n\n' + f'━━━━━━━━━━━━━━━━━━━━━━━━━\n' + f'当前累计 {len(content)} 字,本节最低要求 {min_chars} 字,' + f'全文总差额约 {remaining} 字。\n' + f'请紧接上文末尾继续撰写,要求:\n' + f'(1) 不重复、不复述上文已有段落,自然衔接续写\n' + f'(2) 深入展开实施细节、技术参数、岗位、设备、流程与验收要点\n' + f'(3) 保持"我方"口吻,禁止AI套话与前导说明句\n' + f'(4) 直接输出续写正文,不写"续写如下"等引导语\n' + f'(5) 本轮续写不少于 {chunk_goal} 字,尽量写满\n' + ) + + logger.info( + f'[续写] "{title}" 第{round_i+1}轮 ' + f'({len(content)}/{min_chars}字, 差{remaining}字, 本轮目标≥{chunk_goal}字)' + ) + + try: + extra = ai_client.chat( + cont_prompt, + system=system, + temperature=0.7, + max_tokens=max_tok, + ) + except Exception as e: + logger.warning(f'[续写] "{title}" 第{round_i+1}轮失败: {e}') + break + + if not extra or len(extra.strip()) < 80: + logger.info(f'[续写] "{title}" 第{round_i+1}轮返回内容过短,终止') + break + + content = content.rstrip() + '\n\n' + extra.strip() + logger.info( + f'[续写] "{title}" 第{round_i+1}轮完成,' + f'+{len(extra.strip())}字,累计{len(content)}字' + ) + + logger.info(f'"{title}" 最终字数:{len(content)}') + return content + + +def _build_writing_system(anon_requirements: str = '') -> str: + """根据暗标要求动态构建 system prompt""" + anon = anon_requirements.strip() + if not anon: + return BID_WRITING_SYSTEM + return ( + BID_WRITING_SYSTEM + + '\n\n【暗标合规要求(最高优先级,每个章节均须严格遵守)】\n' + + anon + ) + + +def _get_knowledge_context(title: str) -> str: + """从企业知识库检索与章节标题相关的参考内容,供 AI 写作参考。 + 若知识库未安装或为空,静默返回空字符串。""" + try: + from modules.knowledge import search + chunks = search(title, top_k=config.TOP_K_KNOWLEDGE) + if not chunks: + return '' + parts = [] + for i, chunk in enumerate(chunks, 1): + parts.append(f'[参考片段{i}]\n{chunk[:600]}') + return ( + '\n\n【企业知识库参考内容(以下摘自历史投标文件,仅供参考,' + '须结合本项目实际情况重新撰写,禁止直接照抄)】\n' + + '\n\n'.join(parts) + ) + except Exception: + return '' + + +def _build_diagram_addon(enable_figure: bool, enable_table: bool) -> str: + """构建图/表模式的提示词附加段""" + addon = '' + if enable_figure: + addon += P.get_figure_addon() + if enable_table: + addon += P.get_table_addon() + return addon + + +def _strip_line_serial_numbers(text: str) -> str: + """ + 去除正文行首的纯序号(如 1. / 2、 / 370) / 12 ),保留正文语义。 + """ + if not text: + return text + cleaned_lines = [] + for line in text.splitlines(): + cleaned = re.sub(r'^\s*\d{1,4}(?:[\..、)\s]+)\s*', '', line) + cleaned_lines.append(cleaned) + return '\n'.join(cleaned_lines) + + +def _generate_one(db_path: str, section: dict, summary: str, outline_text: str, + anon_requirements: str = '', + enable_figure: bool = False, + enable_table: bool = False, + boq_summary: str = '', + tender_kind: str = 'engineering') -> None: + """ + 核心生成函数:纯 AI 调用 + 结果写库。 + 不长期持有 DB 连接,适合在线程池中并发调用。 + """ + section_id = section['id'] + is_leaf = bool(section['is_leaf']) + title = section['section_title'] + + writing_system = _build_writing_system(anon_requirements) + diagram_addon = _build_diagram_addon(enable_figure, enable_table) + + _update_section_status_safe(db_path, section_id, 'generating') + + try: + if is_leaf: + volume = getattr(config, 'CONTENT_VOLUME', 'standard') + wc_spec = _get_word_count_spec(volume) + max_tok = _get_max_tokens(volume) + min_chars = _get_min_chars(volume) + + prompt = P.get_section_detail_prompt( + summary, outline_text, title, + word_count_spec=wc_spec, + boq_summary=boq_summary, + tender_kind=tender_kind or 'engineering', + ) + # 知识库检索:将历史标书相关片段作为写作参考注入提示词 + knowledge_ctx = _get_knowledge_context(title) + if knowledge_ctx: + prompt = prompt + knowledge_ctx + + if diagram_addon: + prompt = prompt + diagram_addon + + content = ai_client.chat( + prompt, + system=writing_system, + temperature=0.7, + max_tokens=max_tok, + ) + + content = _auto_continue(content, min_chars, max_tok, title, + system=writing_system) + content = _strip_line_serial_numbers(content) + _update_section_content_safe(db_path, section_id, content, '') + else: + prompt = P.get_section_intro_prompt(summary, outline_text, title) + if prompt: + intro = ai_client.chat( + prompt, + system=writing_system, + temperature=0.4, + max_tokens=1024, + ) + else: + intro = '' + intro = _strip_line_serial_numbers(intro) + _update_section_content_safe(db_path, section_id, '', intro) + + _update_section_status_safe(db_path, section_id, 'done') + logger.info(f'Section {section_id} "{title}" 生成完成') + + except Exception as e: + logger.exception(f'章节生成失败 section_id={section_id}') + _update_section_status_safe(db_path, section_id, 'error', str(e)) + + +def generate_all_sections(db_path: str, project_id: int, + anon_requirements: str = '', + enable_figure: bool = False, + enable_table: bool = False) -> None: + """ + 后台:并发生成所有章节。 + 策略:先生成非叶节点(章节引言),再并发生成所有叶节点(正文)。 + 并发数由 config.MAX_CONCURRENT_SECTIONS 控制,避免超出 API 限流。 + """ + try: + conn = sqlite3.connect(db_path) + cur = conn.cursor() + + # 读取尚未生成的章节(跳过已完成的) + cur.execute(''' + SELECT id, section_number, section_title, level, is_leaf, content, intro_content, status + FROM bid_sections WHERE project_id=? ORDER BY order_index + ''', (project_id,)) + rows = cur.fetchall() + + td = _get_tender_data(conn, project_id) + outline_text = _get_outline_text(conn, project_id) + if not outline_text.strip(): + conn.close() + raise ValueError('当前项目尚无可用大纲,请先保存或生成大纲') + summary = (td or {}).get('summary', '') + boq_summary = (td or {}).get('boq_summary', '') + tender_kind = (td or {}).get('tender_kind', 'engineering') or 'engineering' + outline_head = outline_text.strip().splitlines()[0][:50] if outline_text.strip() else '' + logger.info( + f'全量生成读取大纲 project_id={project_id}, outline_len={len(outline_text)}, outline_head="{outline_head}"' + ) + conn.close() + + all_sections = [ + {'id': r[0], 'section_number': r[1], 'section_title': r[2], + 'level': r[3], 'is_leaf': r[4], 'content': r[5], 'intro_content': r[6], 'status': r[7]} + for r in rows + ] + + # 只处理未完成的章节(pending / error 的重新生成) + sections = [s for s in all_sections if s.get('status') != 'done'] + + if not sections: + logger.info(f'项目 {project_id} 所有章节已生成完成,无需重新生成') + return + + # 分组:非叶节点(章节引言,通常较短)+ 叶节点(正文内容,耗时较长) + non_leaf = [s for s in sections if not s['is_leaf']] + leaf = [s for s in sections if s['is_leaf']] + + workers = max(1, config.MAX_CONCURRENT_SECTIONS) + logger.info( + f'项目 {project_id} 开始并发生成: ' + f'{len(non_leaf)} 个章节引言 + {len(leaf)} 个叶节点, ' + f'并发数={workers}' + ) + + # 第一阶段:并发生成非叶节点引言(通常很快) + if non_leaf: + _concurrent_generate(db_path, non_leaf, summary, outline_text, workers, + anon_requirements, enable_figure, enable_table, + boq_summary, tender_kind) + + # 第二阶段:并发生成叶节点正文(主要耗时部分) + if leaf: + _concurrent_generate(db_path, leaf, summary, outline_text, workers, + anon_requirements, enable_figure, enable_table, + boq_summary, tender_kind) + + # 统计结果 + conn = sqlite3.connect(db_path) + cur = conn.cursor() + cur.execute(''' + SELECT + COUNT(*) as total, + SUM(CASE WHEN status='done' THEN 1 ELSE 0 END) as done, + SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) as errors + FROM bid_sections WHERE project_id=? + ''', (project_id,)) + total, done, errors = cur.fetchone() + conn.close() + logger.info(f'项目 {project_id} 全量生成完成: {done}/{total} 成功, {errors} 失败') + + except Exception as e: + logger.exception(f'全量生成失败 project_id={project_id}') + + +def _concurrent_generate(db_path: str, sections: list, summary: str, + outline_text: str, workers: int, + anon_requirements: str = '', + enable_figure: bool = False, + enable_table: bool = False, + boq_summary: str = '', + tender_kind: str = 'engineering') -> None: + """用线程池并发生成一批章节""" + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix='gen') as pool: + futures = {} + for s in sections: + f = pool.submit(_generate_one, db_path, s, summary, outline_text, + anon_requirements, enable_figure, enable_table, + boq_summary, tender_kind) + futures[f] = s + + for f in as_completed(futures): + s = futures[f] + try: + f.result() + except Exception as e: + logger.error(f'章节 {s["id"]} "{s["section_title"]}" 异常: {e}') + + +# ─── 大纲解析 ───────────────────────────────────────────────────────────── + +_CN_NUMS_LIST = [ + '', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', + '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十', +] + + +def _renumber_sections(sections: list) -> list: + """ + 对章节列表按层级顺序重新编号,确保删除/增减章节后序号连续。 + level 1 → 整数字符串 "1","2",... + level 2 → "1.1","1.2",... + level 3 → "1.1.1","1.1.2",... + level 4 → "1.1.1.1",... + 直接修改传入列表中各节点的 number 字段,并返回该列表。 + """ + counters = [0] * 5 # 索引 0-3 对应 level 1-4 + for s in sections: + level = s['level'] + idx = level - 1 + counters[idx] += 1 + for j in range(idx + 1, len(counters)): + counters[j] = 0 + if level == 1: + s['number'] = str(counters[0]) + else: + s['number'] = '.'.join(str(counters[i]) for i in range(level)) + return sections + + +def _sections_to_outline_text(bid_title: str, sections: list) -> str: + """将章节列表还原为大纲文本(不输出前置序号)。""" + lines = [] + if bid_title: + lines.append(bid_title) + for s in sections: + level = s['level'] + title = s['title'] + indent = '\u3000' * (level - 1) # 全角空格缩进,保持可读性 + lines.append(f'{indent}{title}') + return '\n'.join(lines) + + +def _parse_outline(text: str): + """ + 将大纲文本解析为章节列表,并自动重排序号(修复删除章节后序号不连续的问题)。 + 返回 (bid_title, sections_list, normalized_text) + 每个 section: {number, title, level, is_leaf, order_index} + """ + lines = text.strip().split('\n') + bid_title = '' + sections = [] + order = 0 + + # 第一行非章节行作为标题 + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + is_chapter_line = ( + bool(re.match(r'^[一二三四五六七八九十百第]', stripped)) + or bool(re.match(r'^\d+(?:[..、]\s*|\s+)?\S+', stripped)) + ) + if not is_chapter_line: + bid_title = stripped + lines = lines[i + 1:] + break + break + + chapter_counter = 0 + + for line in lines: + raw_line = line.rstrip('\n') + stripped = raw_line.strip() + if not stripped: + continue + + # 一级:中文数字 + 顿号/句号 + m1 = re.match(r'^([一二三四五六七八九十百]+)[、。.]\s*(.*)', stripped) + if m1: + cn = m1.group(1) + title = m1.group(2).strip() + chapter_counter = CN_NUM_MAP.get(cn, chapter_counter + 1) + sections.append({ + 'number': str(chapter_counter), + 'title': title, + 'level': 1, + 'is_leaf': True, + 'order_index': order, + }) + order += 1 + continue + + # 一级:阿拉伯数字 + 可选分隔(支持 "1 标题"、"1.标题"、"1标题") + m1_en = re.match(r'^(\d+)(?:[、。..]\s*|\s+)?(.*)', stripped) + if m1_en: + chapter_no = int(m1_en.group(1)) + title = (m1_en.group(2) or '').strip() + title = re.sub(r'^[、。..\s]+', '', title) + if title: + chapter_counter = chapter_no + sections.append({ + 'number': str(chapter_counter), + 'title': title, + 'level': 1, + 'is_leaf': True, + 'order_index': order, + }) + order += 1 + continue + + # 二/三/四级:X.X[.X[.X]] + 空格/制表符 + 标题 + m_num = re.match(r'^(\d+(?:\.\d+)+)\s+(.*)', stripped) + if m_num: + num_str = m_num.group(1) + title = m_num.group(2).strip() + level = num_str.count('.') + 1 + sections.append({ + 'number': num_str, + 'title': title, + 'level': min(level, 4), + 'is_leaf': True, + 'order_index': order, + }) + order += 1 + continue + + # 兜底:无编号行按缩进推断层级(支持“纯标题大纲”) + indent_full = len(re.match(r'^[\u3000 ]*', raw_line).group(0)) + # 约定:每 1 个全角空格/2 个半角空格视作 1 级缩进 + level = min(max(1, (indent_full // 2) + 1), 4) + if level == 1: + chapter_counter += 1 + number = str(chapter_counter) + else: + number = '1.' * (level - 1) + '1' + sections.append({ + 'number': number.strip('.'), + 'title': stripped, + 'level': level, + 'is_leaf': True, + 'order_index': order, + }) + order += 1 + + # 重排序号(核心修复:删除章节后确保编号连续) + _renumber_sections(sections) + + # 标记非叶节点(在重排后执行,确保前缀匹配正确) + nums = [s['number'] for s in sections] + for s in sections: + prefix = s['number'] + '.' + if any(n.startswith(prefix) for n in nums): + s['is_leaf'] = False + + # 重建规范大纲文本(供回写数据库) + normalized_text = _sections_to_outline_text(bid_title, sections) + + return bid_title, sections, normalized_text + + +# ─── 数据库工具 ─────────────────────────────────────────────────────────── + +def _get_tender_data(conn, project_id): + cur = conn.cursor() + cur.execute( + "SELECT summary, rating_requirements, rating_json, raw_text, boq_summary, tender_kind " + "FROM tender_data WHERE project_id=?", + (project_id,) + ) + row = cur.fetchone() + if row: + return { + 'summary': row[0], + 'rating_requirements': row[1], + 'rating_json': row[2], + 'raw_text': row[3], + 'boq_summary': row[4] or '', + 'tender_kind': row[5] or 'engineering', + } + return None + + +def _get_outline_text(conn, project_id): + cur = conn.cursor() + cur.execute("SELECT outline FROM tender_data WHERE project_id=?", (project_id,)) + row = cur.fetchone() + return row[0] if row and row[0] else '' + + +def _save_outline_text(conn, project_id, outline_text): + cur = conn.cursor() + # 兜底:若 tender_data 尚未初始化,先补齐空记录,避免 UPDATE 0 行导致“假保存成功” + cur.execute( + "INSERT OR IGNORE INTO tender_data (project_id, status) VALUES (?, 'pending')", + (project_id,), + ) + cur.execute( + "UPDATE tender_data SET outline=?, updated_at=? WHERE project_id=?", + (outline_text, datetime.now(), project_id), + ) + conn.commit() + + +def _save_sections(conn, project_id, sections): + cur = conn.cursor() + # 清除旧章节 + cur.execute("DELETE FROM bid_sections WHERE project_id=?", (project_id,)) + for s in sections: + cur.execute(''' + INSERT INTO bid_sections + (project_id, section_number, section_title, level, is_leaf, order_index, status) + VALUES (?, ?, ?, ?, ?, ?, 'pending') + ''', (project_id, s['number'], s['title'], s['level'], 1 if s['is_leaf'] else 0, s['order_index'])) + conn.commit() + + +def _get_section(conn, section_id): + cur = conn.cursor() + cur.execute( + "SELECT id, section_number, section_title, level, is_leaf, content, intro_content FROM bid_sections WHERE id=?", + (section_id,) + ) + row = cur.fetchone() + if row: + return { + 'id': row[0], 'section_number': row[1], 'section_title': row[2], + 'level': row[3], 'is_leaf': row[4], 'content': row[5], 'intro_content': row[6] + } + return None + + +def _update_section_status(conn, section_id, status, error=''): + cur = conn.cursor() + cur.execute( + "UPDATE bid_sections SET status=?, error_message=?, updated_at=? WHERE id=?", + (status, error, datetime.now(), section_id) + ) + conn.commit() + + +def _update_section_content(conn, section_id, content, intro_content): + cur = conn.cursor() + cur.execute( + "UPDATE bid_sections SET content=?, intro_content=?, updated_at=? WHERE id=?", + (content, intro_content, datetime.now(), section_id) + ) + conn.commit() + + +# ─── 线程安全的数据库操作(每次独立开关连接,启用 WAL)────────────────── + +def _db_connect(db_path: str) -> sqlite3.Connection: + """创建启用 WAL 模式的连接,适合多线程并发写入""" + conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False) + conn.execute('PRAGMA journal_mode=WAL') + return conn + + +def _update_section_status_safe(db_path, section_id, status, error=''): + conn = _db_connect(db_path) + try: + _update_section_status(conn, section_id, status, error) + finally: + conn.close() + + +def _update_section_content_safe(db_path, section_id, content, intro_content): + conn = _db_connect(db_path) + try: + _update_section_content(conn, section_id, content, intro_content) + finally: + conn.close() + + +def _set_project_status(conn, project_id, status, error=''): + cur = conn.cursor() + cur.execute( + "UPDATE projects SET outline_status=?, outline_error=?, updated_at=? WHERE id=?", + (status, error, datetime.now(), project_id) + ) + conn.commit() + + +# ─── AI自动填充小章节 ─────────────────────────────────────────────────────── + +def expand_outline(outline_text: str, summary: str = '', rating_requirements: str = '', + project_id: int = 0) -> str: + """ + 根据用户输入的主章节标题,自动填充子章节。 + """ + lines = outline_text.strip().split('\n') + bid_title = '' + main_chapters = [] + + # 提取标书标题(第一行非章节行且较长时视为标题) + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + is_chapter_format = re.match(r'^[一二三四五六七八九十百第]', stripped) or re.match(r'^\d+[..、\s]', stripped) + if not is_chapter_format and len(stripped) > 50: + bid_title = stripped + lines = lines[i + 1:] + break + break + + # 提取一级章节 + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # 先排除二级及以上章节 + if re.match(r'^\d+(?:\.\d+)+', stripped): + continue + + m1_cn = re.match(r'^([一二三四五六七八九十百]+)[、。..\s]+\s*(.*)', stripped) + if not m1_cn: + m1_cn = re.match(r'^第([一二三四五六七八九十百]+)[章节]\s*(.*)', stripped) + if not m1_cn: + m1_cn = re.match(r'^([一二三四五六七八九十百]+)(?![一二三四五六七八九十百])\s+(.*)', stripped) + + m1_en = re.match(r'^(\d+)[、。..\s]+\s*(.*)', stripped) + if not m1_en: + m1_en = re.match(r'^第(\d+)[章节]\s*(.*)', stripped) + if not m1_en: + m1_en = re.match(r'^(\d+)(?!\d)\s+(.*)', stripped) + if not m1_en: + m1_en = re.match(r'^(\d+)([^\d].*)', stripped) + + if m1_cn or m1_en: + title = (m1_cn.group(2) if m1_cn else m1_en.group(2)).strip() + title = re.sub(r'^[、。..\s]+', '', title) + if title: + main_chapters.append({'title': title}) + else: + # 没有编号的短文本行,也允许作为主章节 + if 0 < len(stripped) < 50: + main_chapters.append({'title': stripped}) + + if not main_chapters: + logger.warning(f'expand_outline未找到主章节,输入大纲:{outline_text[:200]}') + return outline_text + + expanded_lines = [] + if bid_title: + expanded_lines.append(bid_title) + + # 并发生成主章节的小章节 + with ThreadPoolExecutor(max_workers=min(len(main_chapters), 10)) as executor: + future_to_chapter = { + executor.submit( + _generate_sub_chapters, chapter['title'], summary, rating_requirements, idx + 1, project_id + ): (idx, chapter['title']) + for idx, chapter in enumerate(main_chapters) + } + results = [None] * len(main_chapters) + for future in as_completed(future_to_chapter): + idx, title = future_to_chapter[future] + try: + results[idx] = future.result() + logger.info(f'主章节扩展成功: {title}') + except Exception as e: + logger.error(f'主章节扩展失败: {title}, 错误: {e}') + results[idx] = '' + + # 组装结果 + for idx, chapter in enumerate(main_chapters): + chapter_num = idx + 1 + cn_num = _CN_NUMS_LIST[chapter_num] if chapter_num < len(_CN_NUMS_LIST) else str(chapter_num) + expanded_lines.append(f'{cn_num}、{chapter["title"]}') + if results[idx]: + expanded_lines.append(results[idx]) + + return '\n'.join(expanded_lines) + + +def _extract_title_text(title: str) -> str: + """从标题中提取纯文本内容,去除序号和标点符号。""" + text = re.sub(r'^[一二三四五六七八九十百]+[、。.]\s*', '', title.strip()) + text = re.sub(r'^\d+(?:\.\d+)*[、。.]?\s*', '', text) + text = re.sub(r'^\s*[、。,,;;::]+\s*', '', text) + text = re.sub(r'\s*[、。,,;;::]+\s*$', '', text) + return text.strip() + + +def _generate_sub_chapters(chapter_title: str, summary: str, rating_requirements: str, chapter_num: int, + project_id: int = 0) -> str: + """为单个主章节生成子章节大纲。""" + boq_summary = _get_boq_summary_for_chapter(chapter_title, summary) + prompt = P.get_chapter_outline_prompt(summary, chapter_title, rating_requirements) + if boq_summary: + prompt += ( + '\n\n【工程量清单关键信息】\n' + f'{boq_summary}\n\n请严格根据工程量清单中的工程项目生成子章节,确保每个子章节都与具体工程内容对应。' + ) + + try: + response = ai_client.chat( + prompt, + system='你是一位专业的标书大纲生成专家。请根据主章节标题和工程量清单内容生成合适的子章节列表,严格遵守编号规则:' + '绝对禁止出现1.0、2.0、1.0.1等0开头编号;' + '二级从X.1开始,三级从X.1.1开始,四级从X.1.1.1开始;' + '只输出子章节,不重复主章节标题。', + temperature=0.5, + max_tokens=2048, + ) + logger.info(f'_generate_sub_chapters AI响应章节={chapter_title},长度={len(response)}') + + main_title_text = _extract_title_text(chapter_title) + lines = response.strip().split('\n') + level_counts = {1: 0, 2: 0, 3: 0, 4: 0} + result_lines = [] + + for line in lines: + if not line or not line.strip(): + continue + + indent_count = 0 + remaining = line + while remaining and (remaining[0] == '\u3000' or remaining[0] == ' '): + indent_count += 1 + remaining = remaining[1:] + + remaining = re.sub(r'^[\s#*>\-]+', '', remaining).strip() + if not remaining: + continue + + m = re.match(r'^(\d+(?:\.\d+)*)[、。..]?\s*(.*)', remaining) + if m: + original_num = m.group(1) + parts = original_num.split('.') + has_invalid_zero = any(i > 0 and part and part[0] == '0' for i, part in enumerate(parts)) + if has_invalid_zero: + continue + if len(parts) > 1: + level = len(parts) - 1 + else: + if indent_count == 0: + level = 1 + elif indent_count <= 2: + level = 2 + else: + level = 3 + title = m.group(2).strip() + else: + m_cn = re.match(r'^([一二三四五六七八九十百]+)[、。..]\s*(.*)', remaining) + if m_cn: + title = m_cn.group(2).strip() + level = 1 + else: + title = remaining + if indent_count == 0: + level = 1 + elif indent_count <= 2: + level = 2 + else: + level = 3 + + title = _extract_title_text(title) + if not title or len(title) < 2: + continue + + if main_title_text and _extract_title_text(title) == main_title_text: + continue + + level = min(max(level, 1), 3) + level_counts[level] += 1 + for l in range(level + 1, 5): + level_counts[l] = 0 + + if level == 1: + num = f'{chapter_num}.{level_counts[1]}' + indent = '' + elif level == 2: + num = f'{chapter_num}.{level_counts[1]}.{level_counts[2]}' + indent = '\u3000' + else: + num = f'{chapter_num}.{level_counts[1]}.{level_counts[2]}.{level_counts[3]}' + indent = '\u3000\u3000' + + result_lines.append(f'{indent}{num} {title}') + + return '\n'.join(result_lines) + except Exception: + logger.exception(f'生成子章节失败 chapter={chapter_title}') + return '' + + +def _get_boq_summary_for_chapter(chapter_title: str, summary: str) -> str: + """ + 从摘要中提取与施工方案相关的工程量清单信息。 + """ + if not summary: + return '' + + boq_keywords = [ + '项目编码', '清单编码', '编码', '编号', '序号', '项目编号', '清单编号', + '项目名称', '清单名称', '名称', '工程名称', '清单项目名称', '分项名称', + '计量单位', '单位', '计量', '工程量', '数量', '清单数量', '清单工程量', + '综合单价', '单价', '投标单价', '综合价', '合价', '金额', '合计金额', '综合合价', '合计', '总价', '小计', + '项目特征', '项目特征描述', '特征描述', '做法说明', '工程内容', '工作内容', '详述', '说明', '特征', '项目特征及内容', + '施工内容', '工艺要求', '技术措施', '施工要求', '施工方法' + ] + + lines = summary.strip().split('\n') + boq_lines = [] + for line in lines: + if any(keyword in line for keyword in boq_keywords): + boq_lines.append(line.strip()) + + if boq_lines: + return '\n'.join(boq_lines[:20]) + return '' diff --git a/modules/knowledge.py b/modules/knowledge.py new file mode 100644 index 0000000..ff324f3 --- /dev/null +++ b/modules/knowledge.py @@ -0,0 +1,292 @@ +""" +企业知识库模块(无外部向量库依赖) + +存储后端:SQLite(与主数据库共用同一文件) + - knowledge_vectors 表:文本块 + JSON 向量 + - knowledge_files 表:文件元数据(已在 app.py init_db 中建立) + +检索策略: + Qwen / OpenAI provider → Embedding API + 余弦相似度(语义检索) + DeepSeek / Ollama → SQL LIKE 关键词检索(降级) +""" +import json +import math +import logging +import os +import sqlite3 +import threading +from datetime import datetime + +import config +from utils.file_utils import extract_text, split_text_chunks + +logger = logging.getLogger(__name__) + +# 正在后台入库的文件名集合(供前端轮询感知"处理中"状态) +_processing_files: set = set() +_processing_lock = threading.Lock() + +# 每次 Embedding API 批量请求的块数(避免单次请求过大) +_EMBED_BATCH = 16 + + +# ─── 数据库 ────────────────────────────────────────────────────────────────── + +def _conn() -> sqlite3.Connection: + return sqlite3.connect(config.DB_PATH) + + +def _init_tables(cur: sqlite3.Cursor) -> None: + """确保向量块表存在(knowledge_files 已由 app.py init_db 创建)""" + cur.execute(''' + CREATE TABLE IF NOT EXISTS knowledge_vectors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL, + chunk_idx INTEGER NOT NULL, + text TEXT NOT NULL, + embedding TEXT, + UNIQUE(file_name, chunk_idx) + ) + ''') + + +# ─── Embedding API ──────────────────────────────────────────────────────────── + +def _get_embeddings_batch(texts: list[str]) -> list[list[float] | None]: + """ + 调用当前 provider 的 Embedding API,批量返回向量列表。 + 不支持 Embedding 的 provider(DeepSeek / Ollama)返回全 None 列表。 + """ + if not texts: + return [] + + provider = getattr(config, 'MODEL_PROVIDER', '') + try: + from openai import OpenAI + if provider == 'qwen': + client = OpenAI(api_key=config.QWEN_API_KEY, base_url=config.QWEN_BASE_URL) + model = config.QWEN_EMBEDDING_MODEL + elif provider == 'openai': + client = OpenAI(api_key=config.OPENAI_API_KEY, base_url=config.OPENAI_BASE_URL) + model = config.OPENAI_EMBEDDING_MODEL + elif provider == 'kimi': + client = OpenAI(api_key=config.KIMI_API_KEY, base_url=config.KIMI_BASE_URL) + model = config.KIMI_EMBEDDING_MODEL + else: + # DeepSeek / Ollama / 豆包 无公开 Embedding API,降级到关键词检索 + return [None] * len(texts) + + resp = client.embeddings.create(input=texts, model=model) + return [item.embedding for item in resp.data] + + except Exception as e: + logger.warning(f'Embedding API 调用失败,将使用关键词检索降级: {e}') + return [None] * len(texts) + + +def _cosine(a: list[float], b: list[float]) -> float: + """纯 Python 余弦相似度,无需 numpy""" + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(x * x for x in b)) + return dot / (na * nb) if na and nb else 0.0 + + +# ─── 公开接口 ───────────────────────────────────────────────────────────────── + +def is_available() -> dict: + """ + 知识库始终可用(无外部依赖),返回当前状态。 + search_mode: 'vector'(语义检索)或 'keyword'(关键词降级) + """ + with _processing_lock: + processing = list(_processing_files) + + try: + db = _conn() + cur = db.cursor() + _init_tables(cur) + db.commit() + + cur.execute('SELECT COUNT(*) FROM knowledge_vectors') + doc_count = cur.fetchone()[0] + + # 判断是否已有向量(即 Embedding API 是否可用过) + cur.execute('SELECT 1 FROM knowledge_vectors WHERE embedding IS NOT NULL LIMIT 1') + has_embedding = cur.fetchone() is not None + + db.close() + + provider = getattr(config, 'MODEL_PROVIDER', '') + can_embed = provider in ('qwen', 'openai', 'kimi') + mode = 'vector' if (has_embedding or can_embed) else 'keyword' + + return { + 'available': True, + 'doc_count': doc_count, + 'processing': processing, + 'search_mode': mode, + } + except Exception as e: + return { + 'available': True, + 'doc_count': 0, + 'processing': processing, + 'search_mode': 'keyword', + 'error': str(e), + } + + +def add_file(file_path: str, db_path: str) -> dict: + """ + 将文件切块 → 批量 Embedding → 写入 SQLite。 + 此函数在后台线程中调用,_processing_files 用于前端感知进度。 + """ + file_name = os.path.basename(file_path) + with _processing_lock: + _processing_files.add(file_name) + + try: + text = extract_text(file_path) + chunks = split_text_chunks(text, config.CHUNK_SIZE, config.CHUNK_OVERLAP) + if not chunks: + return {'success': False, 'error': '文件内容为空,无法入库'} + + # 批量获取 Embedding(Qwen/OpenAI provider 有效;否则全 None) + embeddings: list[list[float] | None] = [] + for i in range(0, len(chunks), _EMBED_BATCH): + batch = chunks[i:i + _EMBED_BATCH] + embeddings.extend(_get_embeddings_batch(batch)) + + db = _conn() + try: + cur = db.cursor() + _init_tables(cur) + + # 先删除同名文件的旧数据 + cur.execute('DELETE FROM knowledge_vectors WHERE file_name=?', (file_name,)) + + for idx, (chunk, emb) in enumerate(zip(chunks, embeddings)): + emb_json = json.dumps(emb) if emb is not None else None + cur.execute( + 'INSERT INTO knowledge_vectors (file_name, chunk_idx, text, embedding) VALUES (?,?,?,?)', + (file_name, idx, chunk, emb_json), + ) + + cur.execute(''' + INSERT OR REPLACE INTO knowledge_files (file_name, file_path, chunk_count, added_at) + VALUES (?, ?, ?, ?) + ''', (file_name, file_path, len(chunks), datetime.now())) + + db.commit() + finally: + db.close() + + logger.info(f'知识库入库完成: {file_name},{len(chunks)} 块' + f'{"(含向量)" if any(e is not None for e in embeddings) else "(关键词模式)"}') + return {'success': True, 'chunks': len(chunks)} + + except Exception as e: + logger.exception('知识库添加文件失败') + return {'success': False, 'error': str(e)} + finally: + with _processing_lock: + _processing_files.discard(file_name) + + +def search(query: str, top_k: int = None) -> list[str]: + """ + 从知识库检索与 query 最相关的文本块。 + - 向量模式:获取 query 的 Embedding → 余弦相似度排序 + - 关键词模式(降级):SQL LIKE 多词匹配 + """ + if top_k is None: + top_k = config.TOP_K_KNOWLEDGE + + try: + db = _conn() + try: + cur = db.cursor() + _init_tables(cur) + db.commit() + + cur.execute('SELECT COUNT(*) FROM knowledge_vectors') + if cur.fetchone()[0] == 0: + return [] + + # ── 向量语义检索 ────────────────────────────────────────────────── + q_embs = _get_embeddings_batch([query]) + q_emb = q_embs[0] if q_embs else None + + if q_emb is not None: + cur.execute( + 'SELECT text, embedding FROM knowledge_vectors WHERE embedding IS NOT NULL' + ) + rows = cur.fetchall() + if rows: + scored: list[tuple[float, str]] = [] + for text, emb_json in rows: + try: + emb = json.loads(emb_json) + scored.append((_cosine(q_emb, emb), text)) + except Exception: + continue + scored.sort(reverse=True) + return [t for _, t in scored[:top_k]] + + # ── 关键词降级检索(DeepSeek / Ollama 无 Embedding API)───────── + # 过滤纯数字/编号词(如 "1.2" "一、"),避免误匹配无关段落 + import re as _re + _num_pat = _re.compile(r'^[\d\.\-、一二三四五六七八九十]+$') + words = [ + w.strip() for w in query.split() + if len(w.strip()) > 1 and not _num_pat.match(w.strip()) + ][:6] + if not words: + cur.execute('SELECT text FROM knowledge_vectors LIMIT ?', (top_k,)) + return [r[0] for r in cur.fetchall()] + + conditions = ' OR '.join(['text LIKE ?' for _ in words]) + params = [f'%{w}%' for w in words] + [top_k] + cur.execute( + f'SELECT text FROM knowledge_vectors WHERE {conditions} LIMIT ?', params + ) + return [r[0] for r in cur.fetchall()] + + finally: + db.close() + + except Exception as e: + logger.error(f'知识库检索失败: {e}') + return [] + + +def list_files(db_path: str) -> list[dict]: + """列出知识库已入库的文件""" + try: + db = sqlite3.connect(db_path) + cur = db.cursor() + cur.execute( + 'SELECT file_name, chunk_count, added_at FROM knowledge_files ORDER BY added_at DESC' + ) + rows = cur.fetchall() + db.close() + return [{'name': r[0], 'chunks': r[1], 'added_at': r[2]} for r in rows] + except Exception: + return [] + + +def delete_file(file_name: str, db_path: str) -> dict: + """从知识库删除指定文件的所有数据""" + try: + db = _conn() + cur = db.cursor() + _init_tables(cur) + cur.execute('DELETE FROM knowledge_vectors WHERE file_name=?', (file_name,)) + cur.execute('DELETE FROM knowledge_files WHERE file_name=?', (file_name,)) + db.commit() + db.close() + return {'success': True} + except Exception as e: + logger.exception('知识库删除文件失败') + return {'success': False, 'error': str(e)} diff --git a/modules/parser.py b/modules/parser.py new file mode 100644 index 0000000..ca8f1c2 --- /dev/null +++ b/modules/parser.py @@ -0,0 +1,179 @@ +""" +招标文件解析模块 +流程:提取文本 → 生成摘要 → 提取评分要求 → 结构化JSON +""" +import json +import logging +import re +import sqlite3 +from datetime import datetime + +from utils import ai_client, prompts as P +from utils.file_utils import extract_text, truncate_text +from utils.tender_kind_sections import ( + get_tender_kind_classify_prompt, + parse_tender_kind_response, +) + +logger = logging.getLogger(__name__) + + +def parse_boq_file(db_path: str, project_id: int, file_path: str, file_name: str) -> None: + """ + 后台线程:解析工程量清单文件 → 本地结构化分析 → AI 摘要 → 写库。 + boq_status: none → parsing → done / error + """ + from utils.bill_analysis import analyze_boq_pages, categories_to_prompt_appendix + from utils.boq_parser import extract_boq_pages + + conn = sqlite3.connect(db_path) + try: + _set_boq_status(conn, project_id, 'parsing', '正在提取工程量清单文本...') + + page_texts = extract_boq_pages(file_path) + boq_text = '\n'.join(page_texts).strip() + if not boq_text: + raise ValueError('未能从文件中提取到有效内容,请检查文件格式') + + _set_boq_status(conn, project_id, 'parsing', '正在本地解析清单结构...') + analysis = analyze_boq_pages(page_texts) + boq_analysis_json = json.dumps(analysis, ensure_ascii=False) + + structured = '' + if not analysis.get('scanned') and not analysis.get('no_bill_pages'): + structured = categories_to_prompt_appendix(analysis) + + _set_boq_status(conn, project_id, 'parsing', '正在生成工程量清单摘要...') + + summary_prompt = P.get_boq_summary_prompt(boq_text[:10000], structured) + boq_summary = ai_client.chat(summary_prompt, temperature=0.2, max_tokens=2048) + + cur = conn.cursor() + cur.execute(''' + UPDATE tender_data + SET boq_file_name=?, boq_text=?, boq_summary=?, boq_analysis_json=?, + boq_status='done', boq_error='', updated_at=? + WHERE project_id=? + ''', (file_name, boq_text[:12000], boq_summary, boq_analysis_json, datetime.now(), project_id)) + conn.commit() + logger.info(f'项目 {project_id} 工程量清单解析完成') + + except Exception as e: + logger.exception(f'工程量清单解析失败 project_id={project_id}') + _set_boq_status(conn, project_id, 'error', str(e)) + finally: + conn.close() + + +def _set_boq_status(conn, project_id, status, message=''): + cur = conn.cursor() + cur.execute(''' + UPDATE tender_data SET boq_status=?, boq_error=?, updated_at=? + WHERE project_id=? + ''', (status, message, datetime.now(), project_id)) + conn.commit() + + +def parse_tender_file(db_path: str, project_id: int, file_path: str, file_name: str) -> None: + """ + 后台线程中运行:解析招标文件并将结果写入数据库。 + status 字段:pending → parsing → done / error + """ + conn = sqlite3.connect(db_path) + try: + _set_status(conn, project_id, 'parsing', '正在提取文件文本...') + + # 1. 提取原始文本 + raw_text = extract_text(file_path) + raw_text = truncate_text(raw_text, 60000) + + _set_status(conn, project_id, 'parsing', '正在生成招标摘要...') + + # 2. 生成结构化摘要 + summary_prompt = P.get_project_summary_prompt(raw_text) + summary = ai_client.chat(summary_prompt, temperature=0.3, max_tokens=4096) + + _set_status(conn, project_id, 'parsing', '正在提取技术评分要求...') + + # 3. 提取技术评分要求(Markdown 格式) + rating_prompt = P.get_rating_requirements_prompt(raw_text) + rating_md = ai_client.chat(rating_prompt, temperature=0.2, max_tokens=4096) + + _set_status(conn, project_id, 'parsing', '正在结构化评分数据...') + + # 4. 将评分要求转换为 JSON + rating_json_prompt = P.get_rating_json_prompt(rating_md) + rating_json_raw = ai_client.chat(rating_json_prompt, temperature=0.1, max_tokens=2048) + rating_json_str = _clean_json(rating_json_raw) + + _set_status(conn, project_id, 'parsing', '正在识别招标文件类型(工程/服务/货物)...') + excerpt = (raw_text or '')[:15000] + kind_prompt = get_tender_kind_classify_prompt(excerpt) + kind_raw = ai_client.chat(kind_prompt, temperature=0.1, max_tokens=32) + tender_kind = parse_tender_kind_response(kind_raw) + logger.info(f'项目 {project_id} 招标文件类型识别为: {tender_kind}') + + # 写入数据库 + _upsert_tender_data(conn, project_id, file_name, raw_text, + summary, rating_md, rating_json_str, tender_kind) + _set_status(conn, project_id, 'done', '解析完成') + logger.info(f'项目 {project_id} 招标文件解析完成') + + except Exception as e: + logger.exception(f'解析失败 project_id={project_id}') + _set_status(conn, project_id, 'error', str(e)) + finally: + conn.close() + + +# ─── 内部工具 ────────────────────────────────────────────────────────────── + +def _set_status(conn, project_id, status, message=''): + cur = conn.cursor() + cur.execute(''' + INSERT INTO tender_data (project_id, status, error_message) + VALUES (?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET status=?, error_message=?, updated_at=? + ''', (project_id, status, message, status, message, datetime.now())) + conn.commit() + + +def _upsert_tender_data(conn, project_id, file_name, raw_text, + summary, rating_md, rating_json_str, + tender_kind: str = 'engineering'): + cur = conn.cursor() + cur.execute(''' + INSERT INTO tender_data + (project_id, file_name, raw_text, summary, rating_requirements, rating_json, + tender_kind, status, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, 'done', '') + ON CONFLICT(project_id) DO UPDATE SET + file_name=?, raw_text=?, summary=?, rating_requirements=?, + rating_json=?, tender_kind=?, status='done', error_message='', updated_at=? + ''', ( + project_id, file_name, raw_text, summary, rating_md, rating_json_str, tender_kind, + file_name, raw_text, summary, rating_md, rating_json_str, tender_kind, datetime.now() + )) + conn.commit() + + +def _clean_json(raw: str) -> str: + """尝试从 AI 返回中提取 JSON 字符串""" + # 去除 markdown 代码块 + raw = re.sub(r'```(?:json)?\s*', '', raw) + raw = raw.replace('```', '').strip() + # 验证是否是有效 JSON + try: + json.loads(raw) + return raw + except json.JSONDecodeError: + # 尝试提取 { ... } 部分 + m = re.search(r'\{[\s\S]*\}', raw) + if m: + candidate = m.group(0) + try: + json.loads(candidate) + return candidate + except Exception: + pass + return raw diff --git a/prompts/chapter_outline.txt b/prompts/chapter_outline.txt new file mode 100644 index 0000000..75453ef --- /dev/null +++ b/prompts/chapter_outline.txt @@ -0,0 +1,36 @@ +- 角色:技术标书架构师 + +- 能力: + - 单章节深度解构能力 + - 跨章节协同规划视野 + - 评分权重动态分配策略 + +- 任务:根据招标文件概要、章节主题、评分要求,生成结构化的技术标书该章节的目录 + +- 输出要求: + - 采用四级嵌套编码体系(X.X.X.X)确保章节颗粒度可控 + - 直接给出生成的章节大纲,禁止解释和引导词 + - markdown格式输出 + + +- 示例输出,以"服务进度保障措施"为例: + 二、智慧物流系统全生命周期进度保障 +  2.1 基于BIM的进度协同管理平台 +   2.1.1 多级进度计划耦合模型 +    2.1.1.1 WBS-Milestone映射矩阵 +    2.1.1.2 Primavera P6进度基线 +   2.1.2 资源约束进度优化算法 +    2.1.2.1 基于CPM的缓冲区间动态分配 +    2.1.2.2 资源平滑度R=0.92 + +- 招标文件概要: + {summary} + +- 章节主题: + {chapter} + +- 评分要求: + {score} + + + \ No newline at end of file diff --git a/prompts/outlines.txt b/prompts/outlines.txt new file mode 100644 index 0000000..5b65d9e --- /dev/null +++ b/prompts/outlines.txt @@ -0,0 +1,158 @@ +- 角色:技术标书架构师 +- 任务:生成适配技术评分标准的技术标书目录 +- 输出要求: + 采用四级嵌套编码体系(X.X.X.X)下实现按需分层 + 直接给出生成的目录,禁止解释和引导词 + +- 约束控制: + 根据项目生成标书的名称,如“XXXX项目技术标书” + 总的章节数应该控制在8-10个 + 章节颗粒度与评分指标权重正相关 + 技术实施类章节必须达到四级深度,管理保障类章节允许三级结构 + 同级节点数量必须有波动区间:技术方案类(4-7)、实施保障类(2-4)、创新应用类(1-3) + 目录的章节不能缺少包含以下关键词的内容: + - 对本项目的了解和分析 + - 项目工作重难点分析 + - 项目实施方案 + - 服务进度保障措施 + - 服务质量保障方案 + - 合理化建议 + - 服务承诺及处罚措施 + 目录不包含成本和预算内容,但要平衡项目预算、技术可行性以及技术的专业度 + +- 示例输出: + + 花岭新城BIM项目技术标书 + 一、总体实施方案 +  1.1 项目理解与需求分析 +   1.1.1 项目概述 +     1.1.1.1 建设地点及规模 +     1.1.1.2 工程地质勘察报告 +     1.1.1.3 抗震设防烈度与防火等级 +     1.1.1.4 建筑结构形式与建筑面积分布 +   1.1.2 项目背景 +     1.1.2.1 核心宗旨与目标 +     1.1.2.2 地理位置与项目规模 +   1.1.3 项目目标 +     1.1.3.1 就业机会与基础设施提升 +     1.1.3.2 乡村振兴与经济增长 +   1.1.4 项目特点 +     1.1.4.1 框筒结构抗震性能 +     1.1.4.2 分阶段工程地质勘察 +     1.1.4.3 功能区域多样化 + + 二、建筑设计 +  2.1 主要设计依据 +     2.1.1 国家标准与规范 +     2.1.2 行业标准与图集 +  2.2 建筑结构设计 +     2.2.1 结构形式 +     2.2.2 结构材料 +     2.2.3 结构布局 +     2.2.4 结构经济指标 +     2.2.5 结构细节设计 +  2.3 建筑功能布局 +     2.3.1 C1#楼(厂房) +       2.3.1.1 功能分区明确 +       2.3.1.2 流线优化与安全性 +     2.3.2 配电房 +       2.3.2.1 设计目标与设备布置 +       2.3.2.2 空间规划与电气主接线方案 +     2.3.3 外廊及架空建筑 +       2.3.3.1 功能区域与景观设计 +       2.3.3.2 光照与通风优化 +  2.4 建筑材料选用 +  2.5 建筑外观设计 +  2.6 建筑室内布局 +     2.6.1 功能分区与设计要点 +  2.7 建筑安全和消防设计 +     2.7.1 建筑安全体系 +     2.7.2 消防系统设计 +  2.8 建筑节能设计 +     2.8.1 节能措施与绿色建材 +     2.8.2 雨水收集系统 + + 三、结构设计 +  3.1 结构形式 +  3.2 结构材料 +     3.2.1 混凝土与钢材选用 +  3.3 结构布局 +     3.3.1 结构柱网与通风疏散通道 +  3.4 结构经济指标 +     3.4.1 抗震设计要求与用材控制 +  3.5 结构细节设计 +     3.5.1 基础设计与钢结构细节 +     3.5.2 混凝土结构与抗震设计 +  3.6 结构分析与计算 + + 四、给排水设计 +  4.1 引言 +  4.2 供水系统设计 +     4.2.1 供水管道与消防水源 +     4.2.2 节水设计与雨水收集 +  4.3 排水系统设计 +     4.3.1 排水管道与雨水管理 +     4.3.2 污水处理与分流制度 +  4.4 给排水设备选择 +  4.5 细节设计 +  4.6 监测与维护 + + 五、暖通设计 +  5.1 引言 +  5.2 供暖系统设计 +     5.2.1 供暖方式与设备选择 +     5.2.2 温度控制系统 +  5.3 通风系统设计 +     5.3.1 通风方式与设备选择 +     5.3.2 空气质量控制 +  5.4 空调系统设计 +     5.4.1 空调方式与设备选择 +     5.4.2 温湿度控制系统 +  5.5 热水系统设计 +  5.6 细节设计与监测维护 + + + 六、BIM设计 +  6.1 项目总图与单体建筑设计 +  6.2 道路与排水设计 +  6.3 电气系统设计 +  6.4 绿化设计 +  6.5 BIM协同设计与施工管理 +  6.6 数据管理与培训支持 + + 七、设计说明 +  7.1 项目设计依据 +  7.2 设计原则 +  7.3 结构经济合理化 +  7.4 建筑功能分区 +  7.5 设计细节要求 + + 八、合理化建议 +  8.1 建筑专业合理化建议 +  8.2 结构专业合理化建议 +  8.3 给排水专业合理化建议 +  8.4 暖通专业合理化建议 +  8.5 BIM专业合理化建议 + 8.6 技术和工艺方面的建议 + 8.7 成本和预算方面的建议 + 8.8 时间和进度方面的建议 + 8.9 施工质量管理方面的建议 + 8.10 质量和安全方面的建议 + 8.11 环境和可持续性方面的建议 + + 九、施工进度安排 +  9.1 施工进度安排 +  9.2 施工进度跟踪与管理 +  9.3 施工质量管理 +  9.4 施工现场管理 +  9.5 施工结项与验收 + + 十、本项目工作重点难点分析 +  10.1 工程特点与设计工作难点 +  10.2 重点与难点分析 +  10.3 综合解决措施 + + +- 招标文件内容: +{document_text} +""" \ No newline at end of file diff --git a/prompts/outlines_with_rating.txt b/prompts/outlines_with_rating.txt new file mode 100644 index 0000000..c635ab0 --- /dev/null +++ b/prompts/outlines_with_rating.txt @@ -0,0 +1,155 @@ +- 角色:技术标书架构师 +- 任务:生成适配技术评分标准的技术标书目录 +- 输出要求: + 采用四级嵌套编码体系(X.X.X.X)下实现按需分层 + 直接给出生成的目录,禁止解释和引导词 + +- 约束控制: + 根据项目生成标书的名称,如“XXXX项目技术标书” + 总的章节数应该控制在8-10个,不超过10个 + 目录的章节必须按照技术评分标准的项目生成,题目应包括技术评分项目中的关键词: + 章节颗粒度与评分指标权重正相关 + 技术方案类章节必须达到四级深度,管理保障类章节允许三级结构 + 同级节点数量必须有波动区间:技术方案类(4-7)、实施保障类(2-4)、创新应用类(1-3) + 目录禁止包含报价、团队、资质、文件等商务性质的章节 + +- 示例输出: + + 花岭新城BIM项目技术标书 + 一、总体实施方案 +  1.1 项目理解与需求分析 +   1.1.1 项目概述 +     1.1.1.1 建设地点及规模 +     1.1.1.2 工程地质勘察报告 +     1.1.1.3 抗震设防烈度与防火等级 +     1.1.1.4 建筑结构形式与建筑面积分布 +   1.1.2 项目背景 +     1.1.2.1 核心宗旨与目标 +     1.1.2.2 地理位置与项目规模 +   1.1.3 项目目标 +     1.1.3.1 就业机会与基础设施提升 +     1.1.3.2 乡村振兴与经济增长 +   1.1.4 项目特点 +     1.1.4.1 框筒结构抗震性能 +     1.1.4.2 分阶段工程地质勘察 +     1.1.4.3 功能区域多样化 + + 二、建筑设计 +  2.1 主要设计依据 +     2.1.1 国家标准与规范 +     2.1.2 行业标准与图集 +  2.2 建筑结构设计 +     2.2.1 结构形式 +     2.2.2 结构材料 +     2.2.3 结构布局 +     2.2.4 结构经济指标 +     2.2.5 结构细节设计 +  2.3 建筑功能布局 +     2.3.1 C1#楼(厂房) +       2.3.1.1 功能分区明确 +       2.3.1.2 流线优化与安全性 +     2.3.2 配电房 +       2.3.2.1 设计目标与设备布置 +       2.3.2.2 空间规划与电气主接线方案 +     2.3.3 外廊及架空建筑 +       2.3.3.1 功能区域与景观设计 +       2.3.3.2 光照与通风优化 +  2.4 建筑材料选用 +  2.5 建筑外观设计 +  2.6 建筑室内布局 +     2.6.1 功能分区与设计要点 +  2.7 建筑安全和消防设计 +     2.7.1 建筑安全体系 +     2.7.2 消防系统设计 +  2.8 建筑节能设计 +     2.8.1 节能措施与绿色建材 +     2.8.2 雨水收集系统 + + 三、结构设计 +  3.1 结构形式 +  3.2 结构材料 +     3.2.1 混凝土与钢材选用 +  3.3 结构布局 +     3.3.1 结构柱网与通风疏散通道 +  3.4 结构经济指标 +     3.4.1 抗震设计要求与用材控制 +  3.5 结构细节设计 +     3.5.1 基础设计与钢结构细节 +     3.5.2 混凝土结构与抗震设计 +  3.6 结构分析与计算 + + 四、给排水设计 +  4.1 引言 +  4.2 供水系统设计 +     4.2.1 供水管道与消防水源 +     4.2.2 节水设计与雨水收集 +  4.3 排水系统设计 +     4.3.1 排水管道与雨水管理 +     4.3.2 污水处理与分流制度 +  4.4 给排水设备选择 +  4.5 细节设计 +  4.6 监测与维护 + + 五、暖通设计 +  5.1 引言 +  5.2 供暖系统设计 +     5.2.1 供暖方式与设备选择 +     5.2.2 温度控制系统 +  5.3 通风系统设计 +     5.3.1 通风方式与设备选择 +     5.3.2 空气质量控制 +  5.4 空调系统设计 +     5.4.1 空调方式与设备选择 +     5.4.2 温湿度控制系统 +  5.5 热水系统设计 +  5.6 细节设计与监测维护 + + + 六、BIM设计 +  6.1 项目总图与单体建筑设计 +  6.2 道路与排水设计 +  6.3 电气系统设计 +  6.4 绿化设计 +  6.5 BIM协同设计与施工管理 +  6.6 数据管理与培训支持 + + 七、设计说明 +  7.1 项目设计依据 +  7.2 设计原则 +  7.3 结构经济合理化 +  7.4 建筑功能分区 +  7.5 设计细节要求 + + 八、合理化建议 +  8.1 建筑专业合理化建议 +  8.2 结构专业合理化建议 +  8.3 给排水专业合理化建议 +  8.4 暖通专业合理化建议 +  8.5 BIM专业合理化建议 + 8.6 技术和工艺方面的建议 + 8.7 成本和预算方面的建议 + 8.8 时间和进度方面的建议 + 8.9 施工质量管理方面的建议 + 8.10 质量和安全方面的建议 + 8.11 环境和可持续性方面的建议 + + 九、施工进度安排 +  9.1 施工进度安排 +  9.2 施工进度跟踪与管理 +  9.3 施工质量管理 +  9.4 施工现场管理 +  9.5 施工结项与验收 + + 十、本项目工作重点难点分析 +  10.1 工程特点与设计工作难点 +  10.2 重点与难点分析 +  10.3 综合解决措施 + + +- 招标文件摘要: +{summary} + +- 技术评分标准: +{rating} + +""" \ No newline at end of file diff --git a/prompts/project_summary.txt b/prompts/project_summary.txt new file mode 100644 index 0000000..7c9c715 --- /dev/null +++ b/prompts/project_summary.txt @@ -0,0 +1,92 @@ +- 角色:招标文件编写专家,精通招标文件结构化、摘要编写 + +- 任务:根据用户提供的项目招标文件内容,生成一份专业、清晰的结构化摘要 + +- 要求: + + 一、摘要框架 + 1. 项目概况 + - 项目名称 + - 建设地点 + - 工程性质(新建/改建/扩建) + - 核心建设内容 + - 关键工程量指标 + - 特殊施工工艺(如顶管/盾构等) + - 项目概况 + + 2. 技术要求体系 + - 专业监测要求(分项列出核心监测指标) + - 技术标准规范 + - 质量管控要点 + - 特殊工艺标准 + + 3. 交付物矩阵 + - 阶段性成果清单(含时间节点) + - 最终交付文件要求 + - 成果验收标准 + - 备案审批流程 + + 4. 商务条款摘要 + - 合同期限 + - 支付结构 + - 报价约束条件 + - 违约条款要点 + - 知识产权约定 + + 5. 资质要求矩阵 + - 企业资质门槛 + - 人员资格要求 + - 设备配置标准 + - 同类项目经验 + + 6. 评标要素体系 + - 技术评分维度 + - 商务评分权重 + - 否决性条款 + - 实质性条款 + - 围标识别机制 + + + 二、处理规范 + 1. 信息抽取规则: + - 采用三级信息提炼法(关键数据→技术参数→约束条件) + - 识别并标注法定强制性条款(★号条款) + - 提取特殊工艺参数(例如顶管直径、沉井尺寸等) + + 2. 结构化呈现要求: + - 使用Markdown分级标题系统 + - 技术参数格式化处理 + - 流程节点采用时间轴呈现 + - 关键数据突出显示(例如预算金额、最高限价) + + 3. 专业术语处理: + - 保持行业术语准确性 + - 工程计量单位标准化转换 + - 法律条款原文引述 + + 三、输出示例 + 1.确保包含但不仅限于: + - 项目背景的技术参数分解 + - 监测要求的分类归纳 + - 成果交付的阶段性要求 + - 商务条款的要点提炼 + + 四、质量保障 + 1. 完整性核查清单: + - 验证五证要求(资质/业绩/人员/设备/资金) + - 检查三大核心条款(技术/商务/法律) + - 确认关键日期节点(工期/交付期/质保期) + + 2. 风险提示机制: + - 标注异常约束条款 + - 识别排他性要求 + - 提示潜在履约风险点 + +请严格按照上述结构化框架处理输入的招标文件,生成专业、准确、易读的项目摘要报告。 +输出内容需符合工程领域专业规范,重点数据需二次核验确保准确性。 +严格按照招标文件的内容,确保输出内容的完整性。 +直接给出摘要,禁止说明和引导词。 + +- 用户提供的招标文件内容如下: + {bid_document} + diff --git a/prompts/rating_json.txt b/prompts/rating_json.txt new file mode 100644 index 0000000..5df3ce4 --- /dev/null +++ b/prompts/rating_json.txt @@ -0,0 +1,10 @@ +- 任务:从工程项目招标文件中提取技术评分要求,并以严格的JSON格式输出。 + +- 要求: + 必须生成完整有效的JSON对象,不使用JSON之外的文本说明 + 数值类型字段不添加单位符号 + 包含所有的评分项及其权重分配 + 特殊说明字段仅在存在否决条款(强制性条款)时出现 + +- 技术评分要求内容如下: + {tech_rating} \ No newline at end of file diff --git a/prompts/rating_requirements copy.txt b/prompts/rating_requirements copy.txt new file mode 100644 index 0000000..17abcb3 --- /dev/null +++ b/prompts/rating_requirements copy.txt @@ -0,0 +1,46 @@ +- 角色:招标文件信息提取专家,精通技术评分/技术评审要求的提取 + +- 任务:请严格按照以下步骤分析提供的招标文件内容,并完整提取所有技术评分标准: + +- 步骤与要求: + + 1. **结构解析** + - 首先识别文件整体结构,仅提取“技术评分”/“技术评审”部分 + - 标注评分大类的权重占比(如出现) + + 2. **要素提取** + 对“技术评分”板块进行深度解析,要求: + - 提取评分的全部细节,不能省略 + - 明确列出技术评分的标准,如有(如"ISO认证+3分"、"项目经验每年加1分") + + 3. **结果呈现样例** + 参考以下示例输出markdown结构化格式: + + # 招标技术评分细则 + + ## 技术评分(80分) + - 对本项目的了解和分析(12分) + → 对本项目的理解与项目背景把握准确,对本项目特点、实 施目标和定位内容详尽,完全满足项目需要,科学、合理、 针对性强、合理可行的,得 12 分; 对本项目的理解与项 目背景有一定把握,对本项目特点、实施目标和定位有阐 述说明,基本可行的,得 8 分;对本项目的理解与项目 背景把握片面,对本项目特点、实施目标和定位理解有较 大偏差,可行性较差的,得 4 分;未提供不得分。 + → 合理可行指:( 1)完全响应采购需求;( 2)相关内容的表述具有针对性,全面、具体。 + → 基本可行指:( 1)响应采购需求有微小偏差;( 2)相关 内容的表述有一定的层次性、针对性,但全面性不够。 + → 可行性较差指:( 1)响应采购需求有较大偏差;( 2)相 关内容的表述针对性弱、全面性方面欠缺较大。 + - 项目工作重难点分析(12分) + → 根据供应商针对本项目工作重难点分析与解决方案的科学性、合理性且满足项目实际情况进行评分,项目工作重 难点分析到位、有针对性、完全符合项目实际情况,对应 的解决方案合理可行的,得 12 分; + 项目工作重难点内容 基本准确、针对性一般、基本符合项目实际,对应的解决 方案基本可行的,得 8 分; + 项目工作重难点分析一般,对应的解决方案一般、可行性较差的,得 4 分;未提供 不得分。 + → 合理可行指:( 1)完全响应采购需求;( 2)相关内容的表述具有针对性,全面、具体。 + → 基本可行指:( 1)响应采购需求有微小偏差;( 2)相关 内容的表述有一定的层次性、针对性,但全面性不够。 + → 可行性较差指:( 1)响应采购需求有较大偏差;( 2)相 关内容的表述针对性弱、全面性方面欠缺较大。 + - 项目实施方案(12分) + (继续展开...) + + + +请严格按照上述结构化框架处理输入的招标文件,生成专业、准确的项目技术评分/评审要求。 +严格按照招标文件的内容,确保输出内容的完整性。 +直接输出评分/评审要求,禁止说明和引导词。 + +- 招标文件内容如下: + {bid_document} + + diff --git a/prompts/rating_requirements.txt b/prompts/rating_requirements.txt new file mode 100644 index 0000000..e9f563d --- /dev/null +++ b/prompts/rating_requirements.txt @@ -0,0 +1,43 @@ +- 角色:招标文件信息提取专家,精通技术评分/技术评审要求的提取 + +- 任务:请严格按照以下步骤分析提供的招标文件内容,并完整提取所有技术评分标准: + +- 步骤与要求: + + 1. **结构解析** + - 首先识别文件整体结构,仅提取“技术评分”/“技术评审要求”部分 + - 标注评分大类的权重占比(如出现) + + 2. **要素提取** + 对“技术评分”板块进行深度解析,要求: + - 提取评分的全部细节,不能省略 + - 明确列出量化指标,如有(如"ISO认证+3分"、"项目经验每年加1分") + - 区分强制性条款(必须满足项)与竞争性条款(择优评分项),如有 + - 标注特殊要求(本地化服务、专利数量、团队资质等),如有 + + 3. **异常识别** + - 标出表述模糊的评分项(如"酌情加分""优/良/差等级") + - 识别可能存在的矛盾条款 + - 提示需要注意的隐藏评分点(如投标格式错误扣分项) + + 4. **结果呈现样例** + 参考以下示例输出markdown结构化格式: + + # 招标技术评分细则 + + ## 技术评分(50%) + - 系统架构设计(20%) + → 要求:支持分布式部署(未满足直接废标) + → 加分项:采用微服务架构+3分 + (继续展开...) + + + +请严格按照上述结构化框架处理输入的招标文件,生成专业、准确的项目技术评分要求。 +严格按照招标文件的内容,确保输出内容的完整性。 +直接输出评分要求,禁止说明和引导词。 + +- 招标文件内容如下: + {bid_document} + + diff --git a/prompts/scoring_rules.txt b/prompts/scoring_rules.txt new file mode 100644 index 0000000..ce290b2 --- /dev/null +++ b/prompts/scoring_rules.txt @@ -0,0 +1,45 @@ +"你是一名专业的招标文件分析师,请按照以下步骤处理用户提供的项目招标文件内容: + +1. **结构识别** +- 仔细解析文件结构,定位'评分标准'、'评审办法'、'投标人须知'等关键章节 +- 特别注意包含'分值'、'评分项'、'权重'等关键词的段落 + +2. **核心要素提取** +- 系统提取以下要素形成结构化表格: + │ 类别 │ 评分项名称 │ 分值权重 │ 具体要求 │ 否决条款 │ +- 分类标准: + ● 技术部分(方案设计、实施能力、技术创新等) + ● 商务部分(资质证明、业绩案例、团队经验等) + ● 价格部分(报价合理性、计价方式等) + ● 其他专项(售后服务、本地化服务等) + +3. **深度分析** +- 计算权重配比(示例:技术60% = 方案设计30% + 实施能力20% + 创新10%) +- 识别否决性条款(如"▲"标记项或特定强制要求) +- 标注特殊评分规则:阶梯得分、区间赋分、横向比较等机制 + +4. **风险提示** +- 标出易被忽视的得分点(如ISO认证、专利数量等) +- 识别矛盾条款(如总分值≠100%的情况) +- 提示资质门槛要求(注册资金、特定资质证书等) + +5. **输出格式** +采用Markdown输出以下结构: +```markdown +# 招标评分要点汇总 + +## 核心指标配比 +- 总评分构成:技术分(__%)+ 商务分(__%)+ 价格分(__%) + +## 详细评分矩阵 +| 类别 | 评分项 | 分值 | 具体要求 | 关键指标 | +|------|-------|-----|---------|---------| +| ... | ... | ... | ... | ... | + +## 重点提示 +⚠️ 否决条款:列出所有一票否决项 +💡 得分要点:突出3-5个高权重核心指标 +⏱️ 时间节点:标注与评分相关的时限要求 +``` +请先确认理解任务要求,待用户提供招标文件内容后执行分析。" + diff --git a/prompts/section_detail.py b/prompts/section_detail.py new file mode 100644 index 0000000..d54056f --- /dev/null +++ b/prompts/section_detail.py @@ -0,0 +1,47 @@ +GEN_LEAF_DETAIL_PROMT = """ +【最重要的要求——字数】 +{word_count_spec} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +- 角色:资深投标文件撰写专家 +- 任务:根据招标文件概要、标书目录、子小节标题,撰写该子小节的正文 + +【行文规范】 +- 投标方自称统一用"我方",禁用"我们""本公司" +- 招标人统一称"招标方"或"建设单位" +- 禁止前导句:"本章节对应……""本小节主要说明……""以下将从……方面说明"等——开头直接写实质内容 +- 禁止AI套话:综上所述、首先其次再次、我们深信、高度重视、全力以赴、不断优化、稳步推进、通过以上措施 +- 用具体数据/标准编号/人员配置替代空洞承诺 +- 列举用(1)(2)(3)编号,禁止"首先其次"连接;禁止"等"作结尾 +- 纯文本输出,禁用markdown符号,段落间空行分隔 +- 直接输出正文,不含标题和解释 + +【输入信息】 +- 招标文件概要: +{summary} + +- 技术标书目录: +{outline} + +- 待撰写的子小节标题: +{title} + +再次强调:篇幅是最核心的质量指标。内容必须充分展开,每个技术要点都要详细阐述实施方法、技术参数、人员安排或设备配置。绝不可以概括性一笔带过。 +""" + + +GEN_SECTION_INTRODUCTION_PROMT = """ +- 角色:资深投标文件撰写专家 +- 任务:为章节撰写简短开篇引言(100~200字),点明核心主题与招标要求的对应关系 +- 使用"我方"自称,禁止套话和前导解释句,纯文本输出 +- 若无需过渡可输出空白 + +- 招标文件概要: +{summary} + +- 技术标书目录: +{outline} + +- 章节标题: +{title} +""" diff --git a/prompts/section_details.txt b/prompts/section_details.txt new file mode 100644 index 0000000..396c723 --- /dev/null +++ b/prompts/section_details.txt @@ -0,0 +1,28 @@ +【最重要的要求——字数】 +{word_count_spec} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +- 角色:资深投标文件撰写专家 +- 任务:根据招标文件概要、标书目录、子小节标题,撰写该子小节的正文 + +【行文规范】 +- 投标方自称用"我方","我们","本公司"随机使用 +- 招标人统一称"招标方"或"建设单位" +- 禁止前导句:"本章节对应……""本小节主要说明……""以下将从……方面说明"等——开头直接写实质内容 +- 禁止AI套话:综上所述、首先其次再次、我们深信、高度重视、全力以赴、不断优化、稳步推进、通过以上措施 +- 用具体数据/标准编号/人员配置替代空洞承诺 +- 列举用(1)(2)(3)编号,禁止"首先其次"连接;禁止"等"作结尾 +- 纯文本输出,禁用markdown符号,段落间空行分隔 +- 直接输出正文,不含标题和解释 + +【输入信息】 +- 招标文件概要: +{summary} + +- 技术标书目录: +{outline} + +- 待撰写的子小节标题: +{subsection_title} + +再次强调:篇幅是最核心的质量指标。内容必须充分展开,每个技术要点都要详细阐述实施方法、技术参数、人员安排或设备配置。绝不可以概括性一笔带过。 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5b7189 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +Flask==3.0.3 +flask-cors==4.0.1 +PyPDF2==3.0.1 +python-docx==1.1.2 +openai==1.52.0 +Werkzeug==3.0.4 +requests==2.32.3 +chardet==5.2.0 +pypdf==4.3.1 +pdfminer.six==20231228 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..b359d6f --- /dev/null +++ b/start.bat @@ -0,0 +1,39 @@ +@echo off +title BidPartner - AI Bid Assistant + +echo. +echo ============================================ +echo BidPartner - AI Bid Writing Tool +echo ============================================ +echo. + +cd /d "%~dp0" + +python --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] Python not found. Please install Python 3.9+ + pause + exit /b 1 +) + +if not exist "%~dp0.deps_installed" ( + echo Installing dependencies... + pip install -r requirements.txt + if %errorlevel% neq 0 ( + echo [ERROR] Failed to install dependencies. + pause + exit /b 1 + ) + echo.> "%~dp0.deps_installed" + echo Dependencies installed successfully. +) + +echo Starting server... +echo Open browser: http://localhost:5000 +echo Press Ctrl+C to stop +echo. + +start "" "http://localhost:5000" +python app.py + +pause diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..caf2881 --- /dev/null +++ b/static/style.css @@ -0,0 +1,89 @@ +/* 标伙伴 · 自定义样式 */ + +/* 滚动条美化 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 3px; +} +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* 章节树左侧栏 */ +.sidebar-fixed::-webkit-scrollbar { + width: 4px; +} + +/* 正文内容排版 */ +.prose-content { + font-family: 'SimSun', '宋体', 'Times New Roman', serif; + line-height: 1.9; + color: #374151; +} + +/* 动画 */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.fade-in { + animation: fadeIn 0.25s ease-out; +} + +/* 表格样式(评分要求展示) */ +.markdown-table table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.markdown-table th { + background: #f8fafc; + font-weight: 600; + color: #475569; + padding: 8px 12px; + border: 1px solid #e2e8f0; + text-align: left; +} +.markdown-table td { + padding: 7px 12px; + border: 1px solid #e2e8f0; + color: #334155; +} +.markdown-table tr:nth-child(even) td { + background: #f8fafc; +} + +/* 步骤指示器 */ +.step-active { + background: #2563eb; + color: #fff; + box-shadow: 0 2px 8px rgba(37,99,235,.35); +} + +/* 文件上传拖拽高亮 */ +.drop-active { + border-color: #3b82f6 !important; + background: #eff6ff !important; +} + +/* 章节缩进指示线 */ +.section-indent-line { + border-left: 2px solid #e2e8f0; + margin-left: 8px; + padding-left: 8px; +} + +/* 打印样式 */ +@media print { + header, nav, aside, button { display: none !important; } + main { padding: 0 !important; } + .bg-white { box-shadow: none !important; border: none !important; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..de16646 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,868 @@ + + + + + +标伙伴 · AI 标书助手 + + + + + + + + +
+
+
+
+ + + +
+
+ 标伙伴 + AI 标书助手 +
+
+
+ + +
+
+
+ + +
+ + + + + + + + + +
+ + +
+
+

新建标书项目

+
+ + +
+
+ + +
+
+
+ + +
+
+

AI 模型配置

+ +
+ +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + + +
+
+ + +
+ +
+ + +
+
+ 保守(1路) + 推荐(3-5路) + 激进(10路) +
+
+ +
+ + +
+
+
+ + + + +
+

© 标书老崔

+

本工具仅限学习交流免费使用,生成的技术方案请人工核对。本工具不会在任何平台售卖,请注意甄别。

+
+ + diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000..f971a23 --- /dev/null +++ b/templates/project.html @@ -0,0 +1,2023 @@ + + + + + +{{ project.name }} · 标伙伴 + + + + + + + + +
+
+ + + + + +
+
+ + + +
+

{{ project.name }}

+
+ + + + +
+ + +
+ + + + + +
+ + +
+ + +
+

+ 1 + 上传招标文件 +

+
+ + + + +

拖拽文件到此处,或

+ +

支持 PDF、DOC、DOCX,最大 50MB

+
+ + +
+
+
+ + + +
+
+

+

已上传

+
+
+ +
+ + +
+
+
+ 上传中... +
+
+
+ + +
+
+ + + +

工程量清单导入 (可选)解析后可联动招标内容,让生成内容包含准确工程量

+
+ + +
+ + + + +

拖拽清单文件到此处,或点击选择

+

支持 Excel(xlsx/xls)、CSV、PDF、Word,最大 50MB

+
+ + +
+
+
+ + + +
+
+

+

+
+
+
+ + + + +
+
+ + +
+
+ 上传中... +
+ + +
+ + + + +
+ + +
+ + +
+ + +
+
+

+ + 工程量清单摘要 +

+
+ + +
+
+ +
+ +
+ +

修改后点击"保存",将在生成章节内容时作为工程量参考

+
+
+
+ + +
+
+
+ +
+
+ + + + +
+
+ + +
+ + +
+
+
+

标书类型

+

解析完成后自动识别为工程类 / 服务类 / 货物类;步骤 3 生成章节将套用对应写作模板(施工组织 / 服务方案 / 供货方案)。识别有误可在此修正。

+
+
+ + +
+
+
+ + +
+
+

+ + 招标文件摘要 +

+
+ + +
+
+ +
+ +
+ +

修改后点击"保存",将作为生成大纲的依据

+
+
+ + +
+
+

+ + 技术评分要求 +

+
+ + +
+
+ +
+ +
+ +

修改后点击"保存",将作为生成大纲的依据(只保留技术评分,删除商务/价格评分内容)

+
+
+ +
+
+ + +
+
+

+ 2 + 生成标书大纲 +

+ +
+

请先完成招标文件解析

+ +
+ +
+

AI 将根据招标文件摘要和技术评分标准,生成结构化的四级标书目录。

+ +

+ 当前用于生成的大纲:约 字, 个章节 +

+
+ + +
+
+ AI 正在生成标书大纲,通常需要 30-60 秒... +
+
+ + +
+
+

+ 大纲 + 预览 + 编辑 + ( 个章节) +

+
+ + + + +
+
+ + +
+ +
+ + +
+
+ + + + 保存后将按新大纲重新划分章节,已生成的章节正文内容将被清除,需重新生成。编辑时请保持层级编号格式(一、1.1、1.1.1…)不变。 +
+ +

格式示例:第一行为标书名称,章节用"一、""1.1""1.1.1""1.1.1.1"等格式

+
+
+
+
+ + +
+
+
+

+ 3 + 章节内容生成 +

+
+ + + + + + +
+
+ + +
+
+
+ + + + 暗标模式 + + +
+ +
+ +
+

+ 暗标要求将附加到每个章节的 AI 生成规范中,AI 必须严格遵守。适用于评标文件不得暴露投标人身份的项目。 +

+ +
+ 常用预设: + + + + + + +
+ +

+ 填写后点击「保存暗标要求」,再点击「一键并发生成」或单章节「AI 生成」,暗标规则将自动注入 AI 提示词。 +

+
+
+ 启用暗标模式后,可设置禁止 AI 在生成内容中暴露投标人身份的具体规则。 +
+
+ + +
+
+
+ + + + 图表生成模式 +
+ +
+ +
+ + + + +
+ +

+ 设置后点击「保存设置」,再点击「一键并发生成」或单章节「AI 生成」,图表将自动生成并嵌入正文;导出 Word 文档时自动渲染为带标题的图示块和正式表格。 +

+
+ + +
+
+
+ 生成进度 + + + + + + 路并发 + +
+ +
+
+
+
+
+
+ + + 已完成 + + + + 生成中 + + + + 失败 + +
+ 个章节 +
+
+ +
+

请先生成标书大纲

+ +
+ + +
+ +
+
+
+ + +
+
+

从左侧章节列表或内容列表中选择一个章节

+ +
+ +
+ +
+
+

+
+
+ +
+ + +
+ +
+ + + +
+ +
+ +
+
+
+ + +
+
+ AI 正在生成内容,请稍候... +
+ + +
+ +
+

章节引言

+

+
+ +
+ +
+ +
+
+ + + +

暂无内容,点击"AI 生成"或切换"对话生成"模式

+
+
+
+
+ + +
+ +
+ + +
+
+ AI +
+
+
+ + + +
+
+
+
+ + +
+
+ + +
+

点击 AI 回复下方的「采用此内容 → 填入编辑框」将内容写入编辑器,再点「保存」完成。

+
+
+
+
+ + +
+
+

+ 4 + 合规性检查 +

+

AI 将对照招标要求检查标书内容的覆盖情况,给出改进建议。

+ + + + +
+
+ + +
+ + +
+
+
+
+ + + +
+
+

企业知识库

+

上传历史标书,AI 生成时自动检索企业优势内容

+
+
+ +
+ + + + 语义向量检索 · 个文本块 + + + + + 关键词检索模式 · 个文本块 + +
+
+ + +
+

💡 当前使用关键词检索

+

DeepSeek / Ollama 暂不提供 Embedding API,知识库将以关键词匹配方式检索相关内容。 + 切换为 Qwen 或 OpenAI 模型(在首页 AI 配置中设置)可启用更精准的语义向量检索。

+
+
+ + +
+

+ + + + 添加知识文档 +

+ + + + +

+ 推荐上传:历史技术方案、同类项目标书、企业资质简介、施工工法说明等。
+ 上传后 AI 在生成章节内容时将自动检索相关片段作为写作参考。 +

+
+ + +
+

+ + + + 已上传文件 + +

+ + + + + + + + +
+ + + +

知识库暂无文件

+

上传历史标书后,AI 生成内容时将自动引用

+
+
+ + +
+

+ + + + 使用说明 +

+
    +
  • + 1 + 上传企业历史技术标书、施工方案、资质简介等文档(支持 PDF/DOC/DOCX) +
  • +
  • + 2 + 系统自动将文档切分并向量化入库(首次入库需等待 AI 处理完成) +
  • +
  • + 3 + 生成章节内容(步骤 3)时,系统将自动检索知识库中最相关的段落供 AI 参考写作 +
  • +
  • + 4 + 知识库为全局共享,对所有项目均有效;可随时添加或删除文档 +
  • +
+
+ +
+ +
+
+ + + + + + + +
+

© 标书老崔

+

本工具仅限学习交流免费使用,生成的技术方案请人工核对。本工具不会在任何平台售卖,请注意甄别。

+
+ + diff --git a/tests/test_bill_analysis.py b/tests/test_bill_analysis.py new file mode 100644 index 0000000..b2a2167 --- /dev/null +++ b/tests/test_bill_analysis.py @@ -0,0 +1,52 @@ +"""工程量清单本地分析单元测试。""" +import unittest + +from utils.bill_analysis import ( + analyze_boq_pages, + filter_bill_pages, + parse_bill_text, +) + + +class TestParseBillText(unittest.TestCase): + def test_code_name_unit_qty(self): + text = '010101001001 挖土方 m3 100.5 土壤类别:三类土' + r = parse_bill_text(text) + self.assertIn('categories', r) + self.assertTrue(r['categories']) + cat = r['categories'][0] + self.assertEqual(cat['name'], '未分类') + self.assertEqual(len(cat['items']), 1) + it = cat['items'][0] + self.assertEqual(it['code'], '010101001001') + self.assertIn('挖土', it['name']) + self.assertEqual(it['unit'], 'm3') + self.assertEqual(it['quantity'], '100.5') + + def test_hierarchical_line_prefix(self): + text = '1.1 010101001001 基础开挖 m3 50' + r = parse_bill_text(text) + it = r['categories'][0]['items'][0] + self.assertEqual(it['code'], '010101001001') + + +class TestFilterBillPages(unittest.TestCase): + def test_two_pages_gap_fill(self): + p0 = '目录 前言' + p1 = '分部分项工程量清单\n项目编码 项目名称 工程量\n010101001001 项 m3 1' + p2 = '续表无表头\n010101002001 土 m2 2' + p3 = '规费 税金 社会保险费 住房公积金 其他说明' + pages, meta = filter_bill_pages([p0, p1, p2, p3]) + self.assertEqual(meta['total_pages'], 4) + self.assertGreaterEqual(len(pages), 2) + merged = '\n'.join(pages) + self.assertIn('010101001001', merged) + self.assertIn('010101002001', merged) + + def test_analyze_scanned_empty(self): + r = analyze_boq_pages(['', ' ', '']) + self.assertTrue(r.get('scanned')) + + +if __name__ == '__main__': + unittest.main() diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/utils/ai_client.py b/utils/ai_client.py new file mode 100644 index 0000000..5344811 --- /dev/null +++ b/utils/ai_client.py @@ -0,0 +1,238 @@ +""" +AI API 调用封装,支持 OpenAI、阿里云通义千问、DeepSeek、Ollama(均兼容 OpenAI SDK) +""" +import re +import time +import logging +from openai import OpenAI +import config + +logger = logging.getLogger(__name__) + +PROVIDER_NAMES = { + 'qwen': '通义千问 (Qwen)', + 'deepseek': 'DeepSeek', + 'openai': 'OpenAI', + 'ollama': 'Ollama 本地', + 'doubao': '豆包 (Doubao)', + 'kimi': 'Kimi (Moonshot)', +} + +PROVIDER_LINKS = { + 'qwen': 'https://dashscope.aliyun.com/', + 'deepseek': 'https://platform.deepseek.com/', + 'openai': 'https://platform.openai.com/', + 'ollama': 'https://ollama.com/', + 'doubao': 'https://console.volcengine.com/ark/', + 'kimi': 'https://platform.moonshot.cn/', +} + + +def _check_api_key(): + """调用前预检 API Key,无效时直接抛出友好提示,不做无意义的重试""" + provider = config.MODEL_PROVIDER + + # Ollama 本地无需 API Key,跳过检查 + if provider == 'ollama': + return + + name = PROVIDER_NAMES.get(provider, provider) + link = PROVIDER_LINKS.get(provider, '') + + if provider == 'qwen': + key = config.QWEN_API_KEY + elif provider == 'deepseek': + key = config.DEEPSEEK_API_KEY + elif provider == 'doubao': + key = config.DOUBAO_API_KEY + elif provider == 'kimi': + key = config.KIMI_API_KEY + else: + key = config.OPENAI_API_KEY + + if not key or key.startswith('sk-your'): + raise RuntimeError( + f'尚未配置 {name} 的 API Key。' + f'请点击右上角设置按钮,选择"{name}"并填入有效的 API Key。' + f'申请地址:{link}' + ) + + +def _get_client() -> OpenAI: + """根据 MODEL_PROVIDER 返回对应的 OpenAI 兼容客户端""" + if config.MODEL_PROVIDER == 'qwen': + return OpenAI(api_key=config.QWEN_API_KEY, base_url=config.QWEN_BASE_URL) + if config.MODEL_PROVIDER == 'deepseek': + return OpenAI(api_key=config.DEEPSEEK_API_KEY, base_url=config.DEEPSEEK_BASE_URL) + if config.MODEL_PROVIDER == 'ollama': + return OpenAI(api_key='ollama', base_url=config.OLLAMA_BASE_URL) + if config.MODEL_PROVIDER == 'doubao': + return OpenAI(api_key=config.DOUBAO_API_KEY, base_url=config.DOUBAO_BASE_URL) + if config.MODEL_PROVIDER == 'kimi': + return OpenAI(api_key=config.KIMI_API_KEY, base_url=config.KIMI_BASE_URL) + return OpenAI(api_key=config.OPENAI_API_KEY, base_url=config.OPENAI_BASE_URL) + + +def _get_model() -> str: + if config.MODEL_PROVIDER == 'qwen': + return config.QWEN_MODEL + if config.MODEL_PROVIDER == 'deepseek': + return config.DEEPSEEK_MODEL + if config.MODEL_PROVIDER == 'ollama': + return config.OLLAMA_MODEL + if config.MODEL_PROVIDER == 'doubao': + return config.DOUBAO_MODEL + if config.MODEL_PROVIDER == 'kimi': + return config.KIMI_MODEL + return config.OPENAI_MODEL + + +def _clean_response(text: str) -> str: + """ + 过滤推理模型(DeepSeek R1 / QwQ 等)输出的 ... 思考过程标签, + 只保留最终正文内容,避免思考链污染标书正文。 + """ + # 去除 ... 块(含跨行内容) + text = re.sub(r'[\s\S]*?', '', text, flags=re.IGNORECASE) + return text.strip() + + +def _is_auth_error(e: Exception) -> bool: + """判断是否为认证错误(401 / invalid_api_key),无需重试""" + # 优先用 openai 原生异常类型判断 + try: + from openai import AuthenticationError, PermissionDeniedError + if isinstance(e, (AuthenticationError, PermissionDeniedError)): + return True + except ImportError: + pass + # 兜底:字符串匹配 + err_str = str(e).lower() + return ('401' in err_str or 'invalid_api_key' in err_str + or 'incorrect api key' in err_str or 'authentication' in err_str) + + +# OpenAI o 系列推理模型:不支持 temperature,max_tokens 需用 max_completion_tokens +_OPENAI_REASONING_MODELS = {'o1', 'o1-mini', 'o1-pro', 'o3', 'o3-mini', 'o3-pro', 'o4-mini'} + + +def _build_chat_kwargs(model: str, messages: list, temperature: float, max_tokens: int) -> dict: + """ + 根据模型类型构建 chat.completions.create 的参数字典。 + OpenAI o 系列推理模型不接受 temperature,且使用 max_completion_tokens 替代 max_tokens。 + """ + base_model = model.split(':')[0] # 去掉 ollama tag 后缀 + is_reasoning = base_model in _OPENAI_REASONING_MODELS + + kwargs = { + 'model': model, + 'messages': messages, + 'timeout': config.REQUEST_TIMEOUT, + } + if is_reasoning: + kwargs['max_completion_tokens'] = max_tokens + else: + kwargs['temperature'] = temperature + kwargs['max_tokens'] = max_tokens + return kwargs + + +def chat(prompt: str, system: str = '你是一位专业的投标文件撰写专家。', + temperature: float = 0.7, max_tokens: int = 8192, + retries: int = None) -> str: + """ + 调用 AI 接口,返回文本响应。 + 认证错误立即终止;其他错误指数退避重试。 + 自动兼容 OpenAI o 系列推理模型的参数差异。 + """ + _check_api_key() + + max_retries = retries if retries is not None else config.MAX_RETRIES + client = _get_client() + model = _get_model() + provider = config.MODEL_PROVIDER + name = PROVIDER_NAMES.get(provider, provider) + + messages = [ + {'role': 'system', 'content': system}, + {'role': 'user', 'content': prompt}, + ] + + for attempt in range(max_retries): + try: + kwargs = _build_chat_kwargs(model, messages, temperature, max_tokens) + resp = client.chat.completions.create(**kwargs) + return _clean_response(resp.choices[0].message.content.strip()) + except Exception as e: + if _is_auth_error(e): + raise RuntimeError( + f'{name} API Key 无效或已过期,请在设置中重新配置。' + f'申请地址:{PROVIDER_LINKS.get(provider, "")}' + ) from e + + wait = 2 ** attempt + logger.warning(f'AI 请求失败 (第{attempt+1}次),{wait}s 后重试: {e}') + if attempt < max_retries - 1: + time.sleep(wait) + else: + raise RuntimeError(f'AI 接口调用失败(已重试 {max_retries} 次): {e}') from e + + return '' + + +def chat_with_history(system: str, messages: list, + temperature: float = 0.7, max_tokens: int = 4096) -> str: + """ + 多轮对话接口,支持完整历史上下文,用于对话式章节生成。 + messages 格式:[{'role': 'user'|'assistant', 'content': str}, ...] + """ + _check_api_key() + + client = _get_client() + model = _get_model() + provider = config.MODEL_PROVIDER + name = PROVIDER_NAMES.get(provider, provider) + + full_messages = [{'role': 'system', 'content': system}] + messages + + for attempt in range(config.MAX_RETRIES): + try: + kwargs = _build_chat_kwargs(model, full_messages, temperature, max_tokens) + resp = client.chat.completions.create(**kwargs) + return _clean_response(resp.choices[0].message.content.strip()) + except Exception as e: + if _is_auth_error(e): + raise RuntimeError( + f'{name} API Key 无效或已过期,请在设置中重新配置。' + f'申请地址:{PROVIDER_LINKS.get(provider, "")}' + ) from e + wait = 2 ** attempt + logger.warning(f'对话 AI 请求失败 (第{attempt+1}次),{wait}s 后重试: {e}') + if attempt < config.MAX_RETRIES - 1: + time.sleep(wait) + else: + raise RuntimeError(f'AI 接口调用失败(已重试 {config.MAX_RETRIES} 次): {e}') from e + + return '' + + +def get_embeddings(texts: list[str]) -> list[list[float]]: + """获取文本嵌入向量。 + 支持 Qwen、OpenAI、Kimi;DeepSeek / Ollama / 豆包 暂不提供 Embedding API。 + """ + provider = config.MODEL_PROVIDER + if provider in ('deepseek', 'ollama', 'doubao'): + raise NotImplementedError( + f'{PROVIDER_NAMES.get(provider)} 暂不支持 Embedding API,知识库将使用关键词检索降级' + ) + + client = _get_client() + if provider == 'qwen': + model = config.QWEN_EMBEDDING_MODEL + elif provider == 'kimi': + model = config.KIMI_EMBEDDING_MODEL + else: + model = config.OPENAI_EMBEDDING_MODEL + + resp = client.embeddings.create(model=model, input=texts) + return [item.embedding for item in resp.data] diff --git a/utils/bill_analysis.py b/utils/bill_analysis.py new file mode 100644 index 0000000..1a3c90d --- /dev/null +++ b/utils/bill_analysis.py @@ -0,0 +1,577 @@ +""" +工程量清单本地分析(从 bill-worker.js Phase 2/3 移植)。 +Phase 2:按页关键字筛选清单页;Phase 3:正则解析分部与清单项。 +""" +from __future__ import annotations + +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +BILL_KW = ['项目编码', '项目名称', '工程量', '计量单位', '综合单价', '清单编码'] +SEC_KW = ['分部分项', '分类分项', '措施项目', '其他项目', '工程量清单计价'] +FEE_PAGE_KW = [ + '规费', '税金', '社会保险费', '住房公积金', '养老保险', + '工伤保险', '失业保险', '医疗保险', '教育费附加', '城市维护建设税', +] + +ITEM_START = re.compile(r'^\d+(\.\d+)+\s') +CODE_INLINE = re.compile(r'(?:^|\s)(\d{9,12}|(? str: + def repl(m: re.Match) -> str: + a, b, c, d = m.group(1), m.group(2), m.group(3), m.group(4) or '' + combined = a + b + c + d + if 9 <= len(combined) <= 12: + return combined + return m.group(0) + + return _DASH_CODE.sub(repl, line) + + +def is_fee_item(name: str) -> bool: + if not name: + return False + n = re.sub(r'\s+', '', name) + if n in _EXACT_FEE_ITEM: + return True + for kw in _FEE_KW: + if kw in n: + return True + return False + + +def split_name_and_spec(raw_name: str) -> tuple[str, str]: + if not raw_name: + return '', '' + m = re.search(r'\d+[.、.)\uFF09]\s*[\u4e00-\u9fff]', raw_name) + if m and m.start() > 0: + return raw_name[:m.start()].strip(), raw_name[m.start():].strip() + kw = _SPEC_KW_RE.search(raw_name) + if kw and kw.start() > 0: + return raw_name[:kw.start()].strip(), raw_name[kw.start():].strip() + paren = re.search(r'[((]\d+[))]', raw_name) + if paren and paren.start() > 0: + return raw_name[:paren.start()].strip(), raw_name[paren.start():].strip() + return raw_name, '' + + +def is_cat_title(text: str) -> bool: + return any(k in text for k in _CAT_KW) + + +def is_fee_cat_title(text: str) -> bool: + if not text: + return False + t = re.sub(r'\s+', '', text) + if t in _EXACT_FEE_CAT: + return True + for kw in _FEE_CAT_KW: + if kw in t: + return True + return False + + +def _is_new_line_trigger(raw: str) -> bool: + if ITEM_START.match(raw): + return True + if CODE_START_RE.match(raw): + return True + if SEQ_CODE_RE.match(raw): + return True + for m in CATEGORY_MARKERS: + if raw.startswith(m + ' ') or raw.startswith(m + '\u3000'): + return True + return False + + +def parse_bill_text(text: str) -> dict[str, Any]: + raw_lines = [] + for l in text.split('\n'): + line = l.replace('\t', ' ').strip() + line = _fold_dash_codes(line) + raw_lines.append(line) + + logic_lines: list[str] = [] + current_line = '' + + for raw in raw_lines: + if not raw or PAGE_MARK.match(raw): + continue + if HEADER_RE.match(raw) or HEADER_KW.match(raw): + continue + if re.match(r'^(元)|^款章节号|^备注$|^第\d+页', raw): + continue + + if _is_new_line_trigger(raw): + if current_line: + logic_lines.append(current_line) + current_line = raw + elif CODE_INLINE.search(raw) and len(raw) > 15: + if current_line: + logic_lines.append(current_line) + current_line = raw + else: + if current_line and len(current_line) > 300: + logic_lines.append(current_line) + current_line = raw + else: + current_line = current_line + ' ' + raw if current_line else raw + if current_line: + logic_lines.append(current_line) + + logger.debug('合并后 %s 条逻辑行(原始 %s 行)', len(logic_lines), len(raw_lines)) + + categories: list[dict[str, Any]] = [] + cur_cat: dict[str, Any] | None = None + cur_item: dict[str, Any] | None = None + + for line in logic_lines: + if SKIP_RE.search(line): + continue + + # 行首序号:多级如「1.1.1.1 」;或「1–4 位序号 + 空格 + 9 位以上编码」。 + # 避免误删「行首即 9–12 位清单编码 + 空格」整段(JS 原 \d+(\.\d+)* 会吞掉编码)。 + stripped = line.strip() + m_hier = re.match(r'^\d+(?:\.\d+)+\s+', stripped) + if m_hier: + stripped = stripped[m_hier.end():].strip() + elif re.match(r'^\d{1,4}\s+\d{9}', stripped): + stripped = re.sub(r'^\d{1,4}\s+', '', stripped, count=1).strip() + if not stripped: + stripped = line.strip() + if not stripped: + continue + + cm = CODE_RE.search(stripped) + if cm: + if cur_item and cur_cat: + cur_cat['items'].append(cur_item) + if not cur_cat: + cur_cat = {'name': '未分类', 'items': []} + categories.append(cur_cat) + + code = cm.group(1) + rest = stripped[cm.end():].strip() + name, unit, quantity, spec = '', '', '', '' + + unit_match = UNIT_RE.search(rest) + if unit_match: + ui = rest.find(unit_match.group(0)) + raw_name = rest[:ui].strip() + unit = unit_match.group(1) + after_unit = rest[ui + len(unit_match.group(0)):].strip() + qm = re.match(r'^([\d,.]+)', after_unit) + if qm: + quantity = qm.group(1) + tail = after_unit[qm.end():].strip() + if tail: + tail_tokens = tail.split() + si = 0 + while si < len(tail_tokens) and re.match(r'^[\d,.%\-]+$', tail_tokens[si]): + si += 1 + spec_tail = ' '.join(tail_tokens[si:]).strip() + if spec_tail: + spec = spec_tail + ns_name, ns_spec = split_name_and_spec(raw_name) + name = ns_name + if ns_spec: + spec = ns_spec + (';' + spec if spec else '') + else: + tokens = [t for t in rest.split() if t] + found_unit_idx = -1 + for ti in range(len(tokens) - 1, 0, -1): + if tokens[ti] in UNIT_SET: + found_unit_idx = ti + break + if found_unit_idx >= 1: + raw_name_str = ' '.join(tokens[:found_unit_idx]) + ns_name, ns_spec = split_name_and_spec(raw_name_str) + name = ns_name + if ns_spec: + spec = ns_spec + unit = tokens[found_unit_idx] + after_tokens = tokens[found_unit_idx + 1:] + if after_tokens and re.match(r'^[\d,.]+$', after_tokens[0]): + quantity = after_tokens[0] + si = 1 + while si < len(after_tokens) and re.match(r'^[\d,.%\-]+$', after_tokens[si]): + si += 1 + spec_tail = ' '.join(after_tokens[si:]).strip() + if spec_tail: + spec = spec + ';' + spec_tail if spec else spec_tail + else: + name = rest + + name = re.sub(r'\s+', '', name).strip() + for u in UNIT_TOKENS: + if name.endswith(u) and len(name) > len(u): + unit = unit or u + name = name[: len(name) - len(u)] + break + + cur_item = {'code': code, 'name': name, 'unit': unit, 'quantity': quantity, 'spec': spec} + continue + + if len(stripped) > 4: + uni_match = UNIT_RE.search(stripped) + if uni_match: + ui = stripped.find(uni_match.group(0)) + before_unit = stripped[:ui].strip() + after_unit = stripped[ui + len(uni_match.group(0)):].strip() + has_qty = bool(re.match(r'^[\d,.]+', after_unit)) + if ( + 2 <= len(before_unit) <= 50 + and has_qty + and re.search(r'[\u4e00-\u9fff]', before_unit) + ): + if cur_item and cur_cat: + cur_cat['items'].append(cur_item) + if not cur_cat: + cur_cat = {'name': '未分类', 'items': []} + categories.append(cur_cat) + unit_fb = uni_match.group(1) + qm = re.match(r'^([\d,.]+)', after_unit) + quantity_fb = qm.group(1) if qm else '' + ns_name, ns_spec = split_name_and_spec(before_unit) + name_fb = re.sub(r'\s+', '', ns_name).strip() + spec_fb = ns_spec or '' + cur_item = {'code': '', 'name': name_fb, 'unit': unit_fb, 'quantity': quantity_fb, 'spec': spec_fb} + continue + + if 2 < len(stripped) < 60 and not CODE_RE.search(stripped): + if UNIT_RE.search(stripped) and re.search(r'\d+\.?\d*\s*$', stripped): + if cur_item: + cur_item['spec'] = (cur_item.get('spec') or '') + ( + ';' + stripped if cur_item.get('spec') else stripped + ) + continue + if is_cat_title(stripped) and not UNIT_RE.search(stripped) and not is_fee_cat_title(stripped): + if cur_item and cur_cat: + cur_cat['items'].append(cur_item) + cur_item = None + clean_title = re.sub( + r'\s+(座|个|项|处|m|km|段|条)\s+\d+[\d.]*\s*$', '', stripped + ).strip() + cur_cat = {'name': clean_title, 'items': []} + categories.append(cur_cat) + continue + + if re.match(r'^[一二三四五六七八九十]+\s', stripped) or re.match( + r'^([一二三四五六七八九十\d]+)', stripped + ): + clean_title = re.sub(r'\s+(座|个|项|处)\s+\d+[\d.]*\s*$', '', stripped).strip() + if is_fee_cat_title(clean_title): + continue + if cur_item and cur_cat: + cur_cat['items'].append(cur_item) + cur_item = None + cur_cat = {'name': clean_title, 'items': []} + categories.append(cur_cat) + continue + + if cur_item and len(stripped) > 1: + cur_item['spec'] = (cur_item.get('spec') or '') + ( + ';' + stripped if cur_item.get('spec') else stripped + ) + + if cur_item and cur_cat: + cur_cat['items'].append(cur_item) + + fee_filtered = 0 + for cat in categories: + if cat.get('items'): + before = len(cat['items']) + cat['items'] = [it for it in cat['items'] if not is_fee_item(it.get('name', ''))] + fee_filtered += before - len(cat['items']) + if fee_filtered: + logger.debug('费用项过滤: 移除 %s 项', fee_filtered) + + total_before_merge = 0 + total_after_merge = 0 + for cat in categories: + items = cat.get('items') or [] + if not items: + continue + total_before_merge += len(items) + name_map: dict[str, dict[str, Any]] = {} + for item in items: + key = re.sub(r'\s+', '', (item.get('name') or '')).strip() + if not key: + continue + if key not in name_map: + name_map[key] = { + 'code': item.get('code') or '', + 'name': item['name'], + 'unit': item.get('unit') or '', + 'quantity': item.get('quantity') or '', + 'spec': item.get('spec') or '', + '_quantities': [item['quantity']] if item.get('quantity') else [], + '_specs': [item['spec']] if item.get('spec') else [], + } + else: + m = name_map[key] + if not m['code'] and item.get('code'): + m['code'] = item['code'] + if not m['unit'] and item.get('unit'): + m['unit'] = item['unit'] + if item.get('quantity'): + m['_quantities'].append(item['quantity']) + if item.get('spec') and item['spec'] not in m['_specs']: + m['_specs'].append(item['spec']) + + merged_items: list[dict[str, str]] = [] + for m in name_map.values(): + qlist = m['_quantities'] + if len(qlist) > 1: + nums = [] + ok = True + for q in qlist: + try: + nums.append(float(q.replace(',', ''))) + except ValueError: + ok = False + break + if ok: + s = sum(nums) + m['quantity'] = str(int(s)) if s % 1 == 0 else f'{s:.2f}' + else: + m['quantity'] = '; '.join(qlist) + elif len(qlist) == 1: + m['quantity'] = qlist[0] + + if m['_specs']: + trimmed = [s[:120] + '...' if len(s) > 120 else s for s in m['_specs']] + m['spec'] = '; '.join(trimmed) + if len(m['spec']) > 300: + m['spec'] = m['spec'][:300] + '...' + for k in ('_quantities', '_specs'): + m.pop(k, None) + merged_items.append( + {k: m[k] for k in ('code', 'name', 'unit', 'quantity', 'spec')} + ) + cat['items'] = merged_items + total_after_merge += len(merged_items) + + merged_count = total_before_merge - total_after_merge + if merged_count > 0: + logger.debug('按名称合并: %s → %s 项', total_before_merge, total_after_merge) + + valid = [c for c in categories if c.get('items')] + total_items = sum(len(c['items']) for c in valid) + logger.debug( + '最终结果: %s 分部, %s 清单项', len(valid), total_items + ) + + return { + 'project_summary': { + 'remark': f'本地解析:{len(valid)} 个分部,{total_items} 个清单项(合并前 {total_before_merge} 项)', + }, + 'categories': valid, + } + + +def filter_bill_pages(page_texts: list[str]) -> tuple[list[str], dict[str, Any]]: + """ + 从按页文本中筛选工程量清单相关页;返回 (bill_page_texts, meta)。 + """ + n = len(page_texts) + meta: dict[str, Any] = {'total_pages': n, 'scanned': False, 'no_bill_pages': False} + + total_chars = sum(len(t or '') for t in page_texts) + if total_chars < 50: + meta['scanned'] = True + meta['reason'] = 'noText' + return [], meta + + bill_flags = [False] * n + for i, t in enumerate(page_texts): + if not (t or '').strip(): + continue + t = t or '' + h_hits = sum(1 for k in BILL_KW if k in t) + s_hit = any(k in t for k in SEC_KW) + has_code = bool(re.search(r'\d{9}', t)) + if h_hits >= 2 or s_hit or has_code: + bill_flags[i] = True + + first_bill = next((i for i, f in enumerate(bill_flags) if f), -1) + last_bill = max((i for i, f in enumerate(bill_flags) if f), default=-1) + if first_bill >= 0 and last_bill > first_bill: + for i in range(first_bill, last_bill + 1): + if bill_flags[i]: + continue + t = page_texts[i] or '' + if not t.strip() or len(t.strip()) <= 30: + continue + fee_hits = sum(1 for kw in FEE_PAGE_KW if kw in t) + if fee_hits >= 2 and not re.search(r'\d{9}', t): + continue + bill_flags[i] = True + + bill_texts = [page_texts[i] for i in range(n) if bill_flags[i]] + if not bill_texts: + meta['no_bill_pages'] = True + + meta['bill_page_indices'] = [i for i in range(n) if bill_flags[i]] + meta['bill_pages'] = len(bill_texts) + return bill_texts, meta + + +def analyze_boq_pages(page_texts: list[str]) -> dict[str, Any]: + """ + 串联筛选 + parse_bill_text;返回结构含 _meta,供持久化与前端。 + """ + total_pages = len(page_texts) + total_chars = sum(len(t or '') for t in page_texts) + + if total_chars < 50: + return { + 'scanned': True, + 'reason': 'noText', + 'totalPages': total_pages, + 'project_summary': {'remark': '文本过少,疑似扫描件或未提取到文字'}, + 'categories': [], + '_meta': { + 'method': 'python-local', + 'total_pages': total_pages, + 'bill_pages': 0, + }, + } + + bill_texts, fmeta = filter_bill_pages(page_texts) + if not bill_texts: + return { + 'scanned': False, + 'no_bill_pages': True, + 'totalPages': total_pages, + 'project_summary': {'remark': '未识别到清单相关页面'}, + 'categories': [], + '_meta': { + 'method': 'python-local', + 'total_pages': total_pages, + 'bill_pages': 0, + **{k: fmeta[k] for k in ('no_bill_pages',) if k in fmeta}, + }, + } + + merged = '\n'.join(bill_texts) + parsed = parse_bill_text(merged) + return { + 'scanned': False, + **parsed, + '_meta': { + 'method': 'python-local', + 'total_pages': total_pages, + 'bill_pages': len(bill_texts), + 'bill_page_indices': fmeta.get('bill_page_indices', []), + }, + } + + +def categories_to_prompt_appendix( + analysis: dict[str, Any], + max_chars: int = 3000, + max_per_cat: int = 40, +) -> str: + """将本地解析结果压成短文本,注入 AI 摘要提示词。""" + cats = analysis.get('categories') or [] + lines: list[str] = [] + for cat in cats: + name = cat.get('name', '') + items = cat.get('items') or [] + lines.append(f'【{name}】') + for it in items[:max_per_cat]: + code = it.get('code') or '-' + n = it.get('name') or '' + u = it.get('unit') or '' + q = it.get('quantity') or '' + lines.append(f' {code} {n} {u} {q}'.strip()) + if len(items) > max_per_cat: + lines.append(f' …共 {len(items)} 条,此处省略其余') + text = '\n'.join(lines).strip() + if len(text) > max_chars: + return text[:max_chars] + '\n…(附录已截断)' + return text diff --git a/utils/boq_parser.py b/utils/boq_parser.py new file mode 100644 index 0000000..0e4cae7 --- /dev/null +++ b/utils/boq_parser.py @@ -0,0 +1,138 @@ +""" +工程量清单解析模块:从 Excel / CSV / PDF / Word 文件中提取结构化文本。 +""" +import csv +import logging +import re +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 最大返回字符数(送给 AI 做摘要时截断) +MAX_BOQ_CHARS = 12000 + + +def extract_boq_text(file_path: str) -> str: + """ + 从工程量清单文件提取原始结构化文本。 + 支持:.xlsx / .xls / .csv / .pdf / .docx / .doc + """ + ext = Path(file_path).suffix.lower() + if ext in ('.xlsx', '.xls'): + text = _extract_excel(file_path) + elif ext == '.csv': + text = _extract_csv(file_path) + elif ext == '.pdf': + from utils.file_utils import _extract_pdf + text = _extract_pdf(file_path) + elif ext == '.docx': + from utils.file_utils import _extract_docx + text = _extract_docx(file_path) + elif ext == '.doc': + from utils.file_utils import _extract_doc + text = _extract_doc(file_path) + else: + raise ValueError(f'不支持的文件格式 {ext},请使用 xlsx/xls/csv/pdf/docx/doc') + + return text[:MAX_BOQ_CHARS] + + +def extract_boq_pages(file_path: str) -> list[str]: + """ + 返回按「页」切分的清单文本:PDF 为每页一段;Excel/CSV/Word 为单元素全文。 + """ + ext = Path(file_path).suffix.lower() + if ext == '.pdf': + from utils.file_utils import extract_pdf_pages + return extract_pdf_pages(file_path) + text = extract_boq_text(file_path) + return [text] if text else [''] + + +# ─── Excel ──────────────────────────────────────────────────────────────── + +def _extract_excel(file_path: str) -> str: + try: + import openpyxl + wb = openpyxl.load_workbook(file_path, data_only=True, read_only=True) + parts = [] + for name in wb.sheetnames: + ws = wb[name] + block = _sheet_to_text(ws, name) + if block.strip(): + parts.append(block) + wb.close() + return '\n\n'.join(parts) + except ImportError: + return _extract_xls_fallback(file_path) + except Exception as e: + raise RuntimeError(f'Excel 解析失败:{e}') from e + + +def _sheet_to_text(ws, sheet_name: str) -> str: + """将一个 Sheet 转为管道分隔文本,自动过滤全空行和全空列。""" + raw_rows = [] + for row in ws.iter_rows(values_only=True): + cells = ['' if v is None else str(v).strip() for v in row] + if any(cells): + raw_rows.append(cells) + + if not raw_rows: + return '' + + # 对齐列数 + max_cols = max(len(r) for r in raw_rows) + raw_rows = [r + [''] * (max_cols - len(r)) for r in raw_rows] + + # 找出有内容的列索引 + active_cols = [j for j in range(max_cols) + if any(raw_rows[i][j] for i in range(len(raw_rows)))] + if not active_cols: + return '' + + lines = [f'【{sheet_name}】'] + for row in raw_rows: + line = ' | '.join(row[j] for j in active_cols) + if line.replace('|', '').strip(): + lines.append(line) + return '\n'.join(lines) + + +def _extract_xls_fallback(file_path: str) -> str: + """旧版 .xls 使用 xlrd 兜底(需安装 xlrd<2)""" + try: + import xlrd # type: ignore + wb = xlrd.open_workbook(file_path) + parts = [] + for sheet in wb.sheets(): + lines = [f'【{sheet.name}】'] + for rx in range(sheet.nrows): + cells = [str(sheet.cell_value(rx, cx)).strip() + for cx in range(sheet.ncols)] + line = ' | '.join(c for c in cells if c) + if line: + lines.append(line) + parts.append('\n'.join(lines)) + return '\n\n'.join(parts) + except Exception as e: + raise RuntimeError(f'.xls 解析失败,请另存为 .xlsx 后重试:{e}') from e + + +# ─── CSV ───────────────────────────────────────────────────────────────── + +def _extract_csv(file_path: str) -> str: + encodings = ['utf-8-sig', 'gbk', 'utf-8', 'gb18030', 'latin-1'] + for enc in encodings: + try: + lines = [] + with open(file_path, 'r', encoding=enc, newline='') as f: + for row in csv.reader(f): + line = ' | '.join(c.strip() for c in row if c.strip()) + if line: + lines.append(line) + return '\n'.join(lines) + except (UnicodeDecodeError, UnicodeError): + continue + except Exception as e: + raise RuntimeError(f'CSV 解析失败:{e}') from e + raise RuntimeError('CSV 文件编码不支持,请另存为 UTF-8 格式后重试') diff --git a/utils/file_utils.py b/utils/file_utils.py new file mode 100644 index 0000000..c23ea3d --- /dev/null +++ b/utils/file_utils.py @@ -0,0 +1,205 @@ +""" +文件处理工具:从 PDF / Word 文件中提取纯文本 +""" +import os +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def extract_text(file_path: str) -> str: + """ + 根据文件扩展名提取文本。 + 支持 .pdf / .docx / .doc + """ + path = Path(file_path) + ext = path.suffix.lower() + + if ext == '.pdf': + return _extract_pdf(file_path) + elif ext == '.docx': + return _extract_docx(file_path) + elif ext == '.doc': + return _extract_doc(file_path) + else: + raise ValueError(f'不支持的文件类型: {ext}') + + +def _extract_pdf(file_path: str) -> str: + """提取 PDF 文本,优先使用 pypdf,回退到 pdfminer""" + try: + from pypdf import PdfReader + reader = PdfReader(file_path) + parts = [] + for page in reader.pages: + text = page.extract_text() + if text: + parts.append(text) + result = '\n'.join(parts) + if result.strip(): + return result + except Exception as e: + logger.warning(f'pypdf 提取失败: {e},尝试 pdfminer') + + try: + from pdfminer.high_level import extract_text as pm_extract + result = pm_extract(file_path) + return result or '' + except Exception as e: + logger.error(f'pdfminer 提取失败: {e}') + raise RuntimeError(f'PDF 文本提取失败: {e}') + + +def extract_pdf_pages(file_path: str) -> list[str]: + """ + 按页提取 PDF 文本(用于工程量清单页筛选)。 + 优先 pypdf 逐页;若各页均无文本则回退 pdfminer 整篇作为单元素列表。 + """ + pages: list[str] = [] + try: + from pypdf import PdfReader + reader = PdfReader(file_path) + for page in reader.pages: + text = page.extract_text() + pages.append((text or '').strip()) + if any(pages): + return pages + except Exception as e: + logger.warning(f'pypdf 按页提取失败: {e},尝试 pdfminer') + + try: + from pdfminer.high_level import extract_text as pm_extract + blob = (pm_extract(file_path) or '').strip() + return [blob] if blob else [''] + except Exception as e: + logger.error(f'pdfminer 提取失败: {e}') + raise RuntimeError(f'PDF 文本提取失败: {e}') + + +def _extract_docx(file_path: str) -> str: + """提取 .docx 文档文本(python-docx)""" + try: + from docx import Document + doc = Document(file_path) + parts = [] + for para in doc.paragraphs: + if para.text.strip(): + parts.append(para.text) + for table in doc.tables: + for row in table.rows: + row_texts = [cell.text.strip() for cell in row.cells if cell.text.strip()] + if row_texts: + parts.append(' '.join(row_texts)) + return '\n'.join(parts) + except Exception as e: + logger.error(f'.docx 提取失败: {e}') + raise RuntimeError(f'Word 文本提取失败: {e}') + + +def _extract_doc(file_path: str) -> str: + """ + 提取旧版 .doc 文件文本,按优先级依次尝试: + 1. win32com(Windows + Microsoft Word 已安装,最准确) + 2. LibreOffice 命令行转换(需安装 LibreOffice) + 3. python-docx 兼容尝试(部分以 XML 保存的伪 .doc 可读) + 全部失败时提示用户手动另存为 .docx + """ + abs_path = str(Path(file_path).resolve()) + + # ── 方案1:win32com(Windows + Word)────────────────────────────────── + try: + import win32com.client + import pythoncom + pythoncom.CoInitialize() + word = None + try: + word = win32com.client.Dispatch('Word.Application') + word.Visible = False + doc = word.Documents.Open(abs_path, ReadOnly=True) + text = doc.Range().Text + doc.Close(False) + logger.info(f'.doc 通过 win32com 提取成功: {file_path}') + return text or '' + finally: + if word: + try: + word.Quit() + except Exception: + pass + pythoncom.CoUninitialize() + except ImportError: + logger.info('pywin32 未安装,跳过 win32com 方案') + except Exception as e: + logger.warning(f'win32com 提取 .doc 失败: {e}') + + # ── 方案2:LibreOffice 命令行 ───────────────────────────────────────── + try: + import subprocess + import tempfile + tmp_dir = tempfile.mkdtemp() + for soffice_cmd in ('soffice', 'libreoffice'): + try: + result = subprocess.run( + [soffice_cmd, '--headless', '--convert-to', 'txt:Text', + '--outdir', tmp_dir, abs_path], + capture_output=True, text=True, timeout=60, + ) + if result.returncode == 0: + txt_file = os.path.join(tmp_dir, Path(file_path).stem + '.txt') + if os.path.exists(txt_file): + with open(txt_file, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + logger.info(f'.doc 通过 LibreOffice 提取成功: {file_path}') + return content + except FileNotFoundError: + continue + except subprocess.TimeoutExpired: + logger.warning('LibreOffice 转换超时') + break + except Exception as e: + logger.warning(f'LibreOffice 提取 .doc 失败: {e}') + + # ── 方案3:python-docx 兼容尝试(部分另存的 .doc 实为 XML 格式)────── + try: + result = _extract_docx(file_path) + if result.strip(): + logger.info(f'.doc 通过 python-docx 兼容读取成功: {file_path}') + return result + except Exception as e: + logger.warning(f'python-docx 兼容读取 .doc 失败: {e}') + + raise RuntimeError( + '无法读取 .doc 格式文件。请在 Word 中打开该文件,' + '选择「另存为」→「Word 文档 (.docx)」后重新上传。' + ) + + +def truncate_text(text: str, max_chars: int = 60000) -> str: + """截断超长文本,避免超出 AI Token 限制""" + if len(text) <= max_chars: + return text + return text[:max_chars] + '\n\n...[文档内容已截断,仅展示前段]' + + +def split_text_chunks(text: str, chunk_size: int = 2000, overlap: int = 200) -> list[str]: + """将文本按固定大小分块(用于知识库)""" + chunks = [] + start = 0 + while start < len(text): + end = min(start + chunk_size, len(text)) + chunks.append(text[start:end]) + start += chunk_size - overlap + return chunks + + +def allowed_file(filename: str) -> bool: + allowed = {'pdf', 'doc', 'docx'} + return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed + + +def safe_filename(filename: str) -> str: + """生成安全的文件名""" + import re + name = re.sub(r'[^\w\u4e00-\u9fff.\-]', '_', filename) + return name diff --git a/utils/prompts.py b/utils/prompts.py new file mode 100644 index 0000000..bd8de43 --- /dev/null +++ b/utils/prompts.py @@ -0,0 +1,902 @@ +""" +所有提示词模板(已内嵌,打包后不暴露明文文件) +""" + +# ── 内嵌提示词常量 ───────────────────────────────────────────────────────── + +PROJECT_SUMMARY = """\ +- 角色:招标文件编写专家,精通招标文件结构化、摘要编写 + +- 任务:根据用户提供的项目招标文件内容,生成一份专业、清晰的结构化摘要 + +- 要求: + + 一、摘要框架 + 1. 项目概况 + - 项目名称 + - 建设地点 + - 工程性质(新建/改建/扩建) + - 核心建设内容 + - 关键工程量指标 + - 特殊施工工艺(如顶管/盾构等) + - 项目概况 + + 2. 技术要求体系 + - 专业监测要求(分项列出核心监测指标) + - 技术标准规范 + - 质量管控要点 + - 特殊工艺标准 + + 3. 交付物矩阵 + - 阶段性成果清单(含时间节点) + - 最终交付文件要求 + - 成果验收标准 + - 备案审批流程 + + 4. 商务条款摘要 + - 合同期限 + - 支付结构 + - 报价约束条件 + - 违约条款要点 + - 知识产权约定 + + 5. 资质要求矩阵 + - 企业资质门槛 + - 人员资格要求 + - 设备配置标准 + - 同类项目经验 + + 6. 评标要素体系 + - 技术评分维度 + - 商务评分权重 + - 否决性条款 + - 实质性条款 + - 围标识别机制 + + + 二、处理规范 + 1. 信息抽取规则: + - 采用三级信息提炼法(关键数据→技术参数→约束条件) + - 识别并标注法定强制性条款(★号条款) + - 提取特殊工艺参数(例如顶管直径、沉井尺寸等) + + 2. 结构化呈现要求: + - 使用Markdown分级标题系统 + - 技术参数格式化处理 + - 流程节点采用时间轴呈现 + - 关键数据突出显示(例如预算金额、最高限价) + + 3. 专业术语处理: + - 保持行业术语准确性 + - 工程计量单位标准化转换 + - 法律条款原文引述 + + 三、输出示例 + 1.确保包含但不仅限于: + - 项目背景的技术参数分解 + - 监测要求的分类归纳 + - 成果交付的阶段性要求 + - 商务条款的要点提炼 + + 四、质量保障 + 1. 完整性核查清单: + - 验证五证要求(资质/业绩/人员/设备/资金) + - 检查三大核心条款(技术/商务/法律) + - 确认关键日期节点(工期/交付期/质保期) + + 2. 风险提示机制: + - 标注异常约束条款 + - 识别排他性要求 + - 提示潜在履约风险点 + +请严格按照上述结构化框架处理输入的招标文件,生成专业、准确、易读的项目摘要报告。 +输出内容需符合工程领域专业规范,重点数据需二次核验确保准确性。 +严格按照招标文件的内容,确保输出内容的完整性。 +直接给出摘要,禁止说明和引导词。 + +- 用户提供的招标文件内容如下: + {bid_document} +""" + +RATING_REQUIREMENTS = """\ +- 角色:招标文件信息提取专家,精通技术评分/技术评审要求的提取 + +- 任务:请严格按照以下步骤分析提供的招标文件内容,**仅提取技术评分标准**,完整输出所有技术评分细则: + +- 重要限制(必须遵守): + ★ 只提取"技术评分"/"技术评审"部分,禁止提取商务评分、价格评分、资质评分、报价等非技术内容 + ★ 若招标文件包含商务/价格评分,直接忽略,不得出现在输出中 + +- 步骤与要求: + + 1. **结构解析** + - 识别文件整体结构,定位"技术评分"/"技术评审要求"章节 + - 标注技术评分的总权重占比(如出现,如"技术分占60%") + - 跳过并忽略商务评分、价格评分、资质评审等非技术评分章节 + + 2. **技术评分要素提取** + 对"技术评分"板块进行完整深度解析: + - 提取全部技术评分细项,不能省略任何子项 + - 明确列出量化指标(如"ISO认证+3分"、"项目经验每年加1分") + - 区分强制性条款(必须满足项/否决项)与竞争性条款(择优评分项) + - 标注特殊技术要求(技术方案、实施能力、技术创新、服务响应等) + - 标注每个评分项的分值/权重 + + 3. **异常识别** + - 标出技术评分中表述模糊的评分项(如"酌情加分""优/良/差等级") + - 识别可能存在的矛盾条款 + - 提示隐藏的技术得分点 + + 4. **结果呈现** + 参考以下示例输出markdown结构化格式: + + # 技术评分细则(技术分共XX分) + + ## 一、技术方案(XX分) + ### 1.1 方案设计(XX分) + → 要求:…… + → 评分标准:…… + + ## 二、实施能力(XX分) + (继续展开...) + +请严格按照上述结构化框架处理输入的招标文件,生成专业、准确的项目技术评分要求。 +严格按照招标文件的内容,确保输出内容的完整性,禁止虚构或补充文件未提及的内容。 +直接输出技术评分要求,禁止说明和引导词。 + +- 招标文件内容如下: + {bid_document} +""" + +RATING_JSON = """\ +- 任务:从工程项目招标文件中提取技术评分要求,并以严格的JSON格式输出。 + +- 要求: + 必须生成完整有效的JSON对象,不使用JSON之外的文本说明 + 数值类型字段不添加单位符号 + 包含所有的评分项及其权重分配 + 特殊说明字段仅在存在否决条款(强制性条款)时出现 + +- 技术评分要求内容如下: + {tech_rating}\ +""" + +OUTLINES = """\ +- 角色:技术标书架构师 +- 任务:生成适配技术评分标准的技术标书目录 +- 输出要求: + 采用四级嵌套编码体系(X.X.X.X)下实现按需分层 + 直接给出生成的目录,禁止解释和引导词 + +- 约束控制: + 根据项目生成标书的名称,如"XXXX项目技术标书" + 总的章节数应该控制在8-10个 + 章节颗粒度与评分指标权重正相关 + 技术实施类章节必须达到四级深度,管理保障类章节允许三级结构 + 同级节点数量必须有波动区间:技术方案类(4-7)、实施保障类(2-4)、创新应用类(1-3) + 目录的章节不能缺少包含以下关键词的内容: + - 对本项目的了解和分析 + - 项目工作重难点分析 + - 项目实施方案 + - 服务进度保障措施 + - 服务质量保障方案 + - 合理化建议 + - 服务承诺及处罚措施 + 目录不包含成本和预算内容,但要平衡项目预算、技术可行性以及技术的专业度 + +- 示例输出: + + 花岭新城BIM项目技术标书 + 一、总体实施方案 +  1.1 项目理解与需求分析 +   1.1.1 项目概述 +     1.1.1.1 建设地点及规模 +     1.1.1.2 工程地质勘察报告 +     1.1.1.3 抗震设防烈度与防火等级 +     1.1.1.4 建筑结构形式与建筑面积分布 +   1.1.2 项目背景 +     1.1.2.1 核心宗旨与目标 +     1.1.2.2 地理位置与项目规模 +   1.1.3 项目目标 +     1.1.3.1 就业机会与基础设施提升 +     1.1.3.2 乡村振兴与经济增长 +   1.1.4 项目特点 +     1.1.4.1 框筒结构抗震性能 +     1.1.4.2 分阶段工程地质勘察 +     1.1.4.3 功能区域多样化 + + 二、建筑设计 +  2.1 主要设计依据 +     2.1.1 国家标准与规范 +     2.1.2 行业标准与图集 +  2.2 建筑结构设计 +     2.2.1 结构形式 +     2.2.2 结构材料 +     2.2.3 结构布局 +     2.2.4 结构经济指标 +     2.2.5 结构细节设计 +  2.3 建筑功能布局 +     2.3.1 C1#楼(厂房) +       2.3.1.1 功能分区明确 +       2.3.1.2 流线优化与安全性 +     2.3.2 配电房 +       2.3.2.1 设计目标与设备布置 +       2.3.2.2 空间规划与电气主接线方案 +     2.3.3 外廊及架空建筑 +       2.3.3.1 功能区域与景观设计 +       2.3.3.2 光照与通风优化 +  2.4 建筑材料选用 +  2.5 建筑外观设计 +  2.6 建筑室内布局 +     2.6.1 功能分区与设计要点 +  2.7 建筑安全和消防设计 +     2.7.1 建筑安全体系 +     2.7.2 消防系统设计 +  2.8 建筑节能设计 +     2.8.1 节能措施与绿色建材 +     2.8.2 雨水收集系统 + + 三、结构设计 +  3.1 结构形式 +  3.2 结构材料 +     3.2.1 混凝土与钢材选用 +  3.3 结构布局 +     3.3.1 结构柱网与通风疏散通道 +  3.4 结构经济指标 +     3.4.1 抗震设计要求与用材控制 +  3.5 结构细节设计 +     3.5.1 基础设计与钢结构细节 +     3.5.2 混凝土结构与抗震设计 +  3.6 结构分析与计算 + + 四、给排水设计 +  4.1 引言 +  4.2 供水系统设计 +     4.2.1 供水管道与消防水源 +     4.2.2 节水设计与雨水收集 +  4.3 排水系统设计 +     4.3.1 排水管道与雨水管理 +     4.3.2 污水处理与分流制度 +  4.4 给排水设备选择 +  4.5 细节设计 +  4.6 监测与维护 + + 五、暖通设计 +  5.1 引言 +  5.2 供暖系统设计 +     5.2.1 供暖方式与设备选择 +     5.2.2 温度控制系统 +  5.3 通风系统设计 +     5.3.1 通风方式与设备选择 +     5.3.2 空气质量控制 +  5.4 空调系统设计 +     5.4.1 空调方式与设备选择 +     5.4.2 温湿度控制系统 +  5.5 热水系统设计 +  5.6 细节设计与监测维护 + + + 六、BIM设计 +  6.1 项目总图与单体建筑设计 +  6.2 道路与排水设计 +  6.3 电气系统设计 +  6.4 绿化设计 +  6.5 BIM协同设计与施工管理 +  6.6 数据管理与培训支持 + + 七、设计说明 +  7.1 项目设计依据 +  7.2 设计原则 +  7.3 结构经济合理化 +  7.4 建筑功能分区 +  7.5 设计细节要求 + + 八、合理化建议 +  8.1 建筑专业合理化建议 +  8.2 结构专业合理化建议 +  8.3 给排水专业合理化建议 +  8.4 暖通专业合理化建议 +  8.5 BIM专业合理化建议 + 8.6 技术和工艺方面的建议 + 8.7 成本和预算方面的建议 + 8.8 时间和进度方面的建议 + 8.9 施工质量管理方面的建议 + 8.10 质量和安全方面的建议 + 8.11 环境和可持续性方面的建议 + + 九、施工进度安排 +  9.1 施工进度安排 +  9.2 施工进度跟踪与管理 +  9.3 施工质量管理 +  9.4 施工现场管理 +  9.5 施工结项与验收 + + 十、本项目工作重点难点分析 +  10.1 工程特点与设计工作难点 +  10.2 重点与难点分析 +  10.3 综合解决措施 + + +- 招标文件内容: +{document_text}\ +""" + +OUTLINES_WITH_RATING = """\ +- 角色:技术标书架构师 +- 任务:生成适配技术评分标准的技术标书目录 +- 输出要求: + 采用四级嵌套编码体系(X.X.X.X)下实现按需分层 + 直接给出生成的目录,禁止解释和引导词 + +- 约束控制: + 根据项目生成标书的名称,如"XXXX项目技术标书" + 总的章节数应该控制在8-10个,不超过10个 + 目录的章节必须按照技术评分标准的项目生成,题目应包括技术评分项目中的关键词: + 章节颗粒度与评分指标权重正相关 + 技术方案类章节必须达到四级深度,管理保障类章节允许三级结构 + 同级节点数量必须有波动区间:技术方案类(4-7)、实施保障类(2-4)、创新应用类(1-3) + 目录禁止包含报价、团队、资质、文件等商务性质的章节 + +- 示例输出: + + 花岭新城BIM项目技术标书 + 一、总体实施方案 +  1.1 项目理解与需求分析 +   1.1.1 项目概述 +     1.1.1.1 建设地点及规模 +     1.1.1.2 工程地质勘察报告 +     1.1.1.3 抗震设防烈度与防火等级 +     1.1.1.4 建筑结构形式与建筑面积分布 +   1.1.2 项目背景 +     1.1.2.1 核心宗旨与目标 +     1.1.2.2 地理位置与项目规模 +   1.1.3 项目目标 +     1.1.3.1 就业机会与基础设施提升 +     1.1.3.2 乡村振兴与经济增长 +   1.1.4 项目特点 +     1.1.4.1 框筒结构抗震性能 +     1.1.4.2 分阶段工程地质勘察 +     1.1.4.3 功能区域多样化 + + 二、建筑设计 +  2.1 主要设计依据 +     2.1.1 国家标准与规范 +     2.1.2 行业标准与图集 +  2.2 建筑结构设计 +     2.2.1 结构形式 +     2.2.2 结构材料 +     2.2.3 结构布局 +     2.2.4 结构经济指标 +     2.2.5 结构细节设计 +  2.3 建筑功能布局 +     2.3.1 C1#楼(厂房) +       2.3.1.1 功能分区明确 +       2.3.1.2 流线优化与安全性 +     2.3.2 配电房 +       2.3.2.1 设计目标与设备布置 +       2.3.2.2 空间规划与电气主接线方案 +     2.3.3 外廊及架空建筑 +       2.3.3.1 功能区域与景观设计 +       2.3.3.2 光照与通风优化 +  2.4 建筑材料选用 +  2.5 建筑外观设计 +  2.6 建筑室内布局 +     2.6.1 功能分区与设计要点 +  2.7 建筑安全和消防设计 +     2.7.1 建筑安全体系 +     2.7.2 消防系统设计 +  2.8 建筑节能设计 +     2.8.1 节能措施与绿色建材 +     2.8.2 雨水收集系统 + + 三、结构设计 +  3.1 结构形式 +  3.2 结构材料 +     3.2.1 混凝土与钢材选用 +  3.3 结构布局 +     3.3.1 结构柱网与通风疏散通道 +  3.4 结构经济指标 +     3.4.1 抗震设计要求与用材控制 +  3.5 结构细节设计 +     3.5.1 基础设计与钢结构细节 +     3.5.2 混凝土结构与抗震设计 +  3.6 结构分析与计算 + + 四、给排水设计 +  4.1 引言 +  4.2 供水系统设计 +     4.2.1 供水管道与消防水源 +     4.2.2 节水设计与雨水收集 +  4.3 排水系统设计 +     4.3.1 排水管道与雨水管理 +     4.3.2 污水处理与分流制度 +  4.4 给排水设备选择 +  4.5 细节设计 +  4.6 监测与维护 + + 五、暖通设计 +  5.1 引言 +  5.2 供暖系统设计 +     5.2.1 供暖方式与设备选择 +     5.2.2 温度控制系统 +  5.3 通风系统设计 +     5.3.1 通风方式与设备选择 +     5.3.2 空气质量控制 +  5.4 空调系统设计 +     5.4.1 空调方式与设备选择 +     5.4.2 温湿度控制系统 +  5.5 热水系统设计 +  5.6 细节设计与监测维护 + + + 六、BIM设计 +  6.1 项目总图与单体建筑设计 +  6.2 道路与排水设计 +  6.3 电气系统设计 +  6.4 绿化设计 +  6.5 BIM协同设计与施工管理 +  6.6 数据管理与培训支持 + + 七、设计说明 +  7.1 项目设计依据 +  7.2 设计原则 +  7.3 结构经济合理化 +  7.4 建筑功能分区 +  7.5 设计细节要求 + + 八、合理化建议 +  8.1 建筑专业合理化建议 +  8.2 结构专业合理化建议 +  8.3 给排水专业合理化建议 +  8.4 暖通专业合理化建议 +  8.5 BIM专业合理化建议 + 8.6 技术和工艺方面的建议 + 8.7 成本和预算方面的建议 + 8.8 时间和进度方面的建议 + 8.9 施工质量管理方面的建议 + 8.10 质量和安全方面的建议 + 8.11 环境和可持续性方面的建议 + + 九、施工进度安排 +  9.1 施工进度安排 +  9.2 施工进度跟踪与管理 +  9.3 施工质量管理 +  9.4 施工现场管理 +  9.5 施工结项与验收 + + 十、本项目工作重点难点分析 +  10.1 工程特点与设计工作难点 +  10.2 重点与难点分析 +  10.3 综合解决措施 + + +- 招标文件摘要: +{summary} + +- 技术评分标准: +{rating}\ +""" + +CHAPTER_OUTLINE = """\ +- 角色:技术标书架构师 + +- 能力: + - 单章节深度解构能力 + - 跨章节协同规划视野 + - 评分权重动态分配策略 + +- 任务:根据招标文件概要、章节主题、评分要求,生成结构化的技术标书该章节的目录 + +- 输出要求: + - 采用四级嵌套编码体系(X.X.X.X)确保章节颗粒度可控 + - 只输出子章节,不输出主章节标题,不要解释和引导词 + - 编号必须从 X.1 开始递增,禁止出现 X.0、X.0.1、01 等编号 + - 允许纯文本输出,不使用 markdown 代码块 + + +- 示例输出,以"服务进度保障措施"为例: + 二、智慧物流系统全生命周期进度保障 +  2.1 基于BIM的进度协同管理平台 +   2.1.1 多级进度计划耦合模型 +    2.1.1.1 WBS-Milestone映射矩阵 +    2.1.1.2 Primavera P6进度基线 +   2.1.2 资源约束进度优化算法 +    2.1.2.1 基于CPM的缓冲区间动态分配 +    2.1.2.2 资源平滑度R=0.92 + +- 招标文件概要: + {summary} + +- 章节主题: + {chapter} + +- 评分要求: + {score}\ +""" + +SECTION_DETAILS = """\ +【字数硬性要求】 +{word_count_spec} +注意:字数须由实质性方案内容支撑,禁止用重复背景、空洞承诺或复述招标要求来凑字数。 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +角色:资深工程投标技术方案撰写专家 +任务:以执行方视角,针对本章节标题所对应的工作内容,撰写具体可操作的技术方案正文。 + +【写作铁律】 +▌写方案,不写回应——开门见山描述具体做法,把招标参数直接融入方案 + × 禁止:"根据招标文件要求,我方将……""针对贵方提出的XXX要求,我方承诺……" +▌不重申已知信息(最常见废稿场景) + 禁止在正文中出现项目名称/建设单位/建设地点/合同工期等基本信息; + 禁止将工程量数字("X条渠道""X公里""X座""X台""X万平方米"等)引入各章节开头 + 作为背景铺垫——这类数字只在"项目概况"章节出现一次,其他章节直接展开专业内容 +▌不复述招标参数——技术规格、工程量、服务数量均已知,直接体现在方案中 +▌不虚构优越参数——招标文件规定的参数/数量/规格是上限基准,不得无依据地写成"优于要求" + × 禁止:招标要求10台,方案里写"我方投入15台以确保万无一失"(无根据拔高) + × 禁止:招标要求C30混凝土,方案里写"我方采用C35以体现高标准"(无依据提升规格) + √ 正确:按招标要求的数量/规格如实描述,竞争力体现在工艺方法和管理措施上 +▌不虚构优越参数——招标文件规定的参数/数量/规格如实描述,不得无依据拔高 + × 禁止:招标要求10台 → 方案写"我方投入15台"(无根据) + × 禁止:招标要求C30混凝土 → 方案写"我方采用C35体现高标准"(无依据) + √ 如需体现竞争力,在工艺方法、管理制度、响应速度等维度展开,不在规格数量上自行拔高 +▌不用套话——禁用:高度重视、全力以赴、竭诚服务、确保圆满完成、综上所述、通过以上措施 +▌格式——纯文本,段落空行分隔,列举用(1)(2)(3),不用markdown符号 + +【参考背景(仅供理解语境,禁止复述到正文中)】 +- 项目概要: +{summary} + +- 标书目录: +{outline} + +【本次撰写的章节标题】 +{subsection_title} + +直接输出正文,不含标题行,不含任何说明语。\ +""" + +SCORING_RULES = """\ +"你是一名专业的招标文件分析师,请按照以下步骤处理用户提供的项目招标文件内容: + +1. **结构识别** +- 仔细解析文件结构,定位'评分标准'、'评审办法'、'投标人须知'等关键章节 +- 特别注意包含'分值'、'评分项'、'权重'等关键词的段落 + +2. **核心要素提取** +- 系统提取以下要素形成结构化表格: + │ 类别 │ 评分项名称 │ 分值权重 │ 具体要求 │ 否决条款 │ +- 分类标准: + ● 技术部分(方案设计、实施能力、技术创新等) + ● 商务部分(资质证明、业绩案例、团队经验等) + ● 价格部分(报价合理性、计价方式等) + ● 其他专项(售后服务、本地化服务等) + +3. **深度分析** +- 计算权重配比(示例:技术60% = 方案设计30% + 实施能力20% + 创新10%) +- 识别否决性条款(如"▲"标记项或特定强制要求) +- 标注特殊评分规则:阶梯得分、区间赋分、横向比较等机制 + +4. **风险提示** +- 标出易被忽视的得分点(如ISO认证、专利数量等) +- 识别矛盾条款(如总分值≠100%的情况) +- 提示资质门槛要求(注册资金、特定资质证书等) + +5. **输出格式** +采用Markdown输出以下结构: +\`\`\`markdown +# 招标评分要点汇总 + +## 核心指标配比 +- 总评分构成:技术分(__%)+ 商务分(__%)+ 价格分(__%) + +## 详细评分矩阵 +| 类别 | 评分项 | 分值 | 具体要求 | 关键指标 | +|------|-------|-----|---------|---------| +| ... | ... | ... | ... | ... | + +## 重点提示 +⚠️ 否决条款:列出所有一票否决项 +💡 得分要点:突出3-5个高权重核心指标 +⏱️ 时间节点:标注与评分相关的时限要求 +\`\`\` +请先确认理解任务要求,待用户提供招标文件内容后执行分析。"\ +""" + +# ── 来自 section_detail.py 的提示词 ──────────────────────────────────────── + +GEN_LEAF_DETAIL_PROMT = """\ +【字数硬性要求】 +{word_count_spec} +注意:字数须由实质性方案内容支撑,禁止用重复背景、空洞承诺或复述招标要求来凑字数。 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +角色:资深工程投标技术方案撰写专家 +任务:以执行方视角,针对本章节标题所对应的工作内容,撰写具体可操作的技术方案正文。 + +【写作方式——铁律,违反即视为废稿】 + +▌写方案,不写回应 +× 错误:"根据招标文件要求,我方将……" +× 错误:"针对贵方提出的XXX要求,我方承诺……" +× 错误:"招标文件明确规定了……对此,我方将……" +√ 正确:开门见山写具体做法,把招标参数直接融入方案中 + +▌不重申已知信息(最常见废稿场景) +× 禁止:在正文中出现项目名称、建设单位、建设地点、合同工期等基本信息 +× 禁止:将招标文件中的具体工程量数字(如"X条渠道""X公里""X座建筑物""X台设备" + "X万平方米"等)引入到本章节开头作为背景铺垫——这类数字只能在"项目概况/背景" + 章节里出现一次,质量管理、安全措施、进度计划、技术方案等专业章节一律直接展开 +× 禁止:重复其他章节已经出现过的项目背景介绍段落 + +▌不虚构优越参数 +× 禁止:招标要求10台 → 写成"我方投入15台以确保万无一失"(无依据拔高数量) +× 禁止:招标要求C30混凝土 → 写成"我方采用C35体现高标准"(无依据提升规格) +× 禁止:招标方规定了参数/工程量 → 写成"我方承诺优于招标要求"(空洞吹捧) +√ 如需体现竞争力,在工艺方法、管理精细度、响应时效等维度展开,不在规格数量上无依据拔高 + +▌不用空话套话 +× 禁用:"高度重视""全力以赴""竭诚服务""确保圆满完成""我方将严格按照" +× 禁用:"综上所述""首先其次再次""通过以上措施" +× 禁用:以"……"或"等"结尾的列举 + +▌能概括的简洁写,有细节的展开写 +- 原则性的管理制度可一段简洁描述 +- 操作步骤、技术参数、人员配置、时间节点等有实质内容的须逐条详细展开 +- 每项措施给出具体方法或量化指标,不写"我方将采取有效措施确保"类句子 + +▌格式 +- 纯文本,段落间空行分隔 +- 列举用(1)(2)(3),不用markdown符号,不用"首先其次" + +【参考背景(仅供理解项目语境,禁止复述到正文中)】 +- 项目概要: +{summary} + +- 标书目录(用于理解本章节在全书中的定位): +{outline} + +【本次撰写的章节标题】 +{title} + +开始撰写,直接输出正文,不含标题行,不含任何说明语。\ +""" + +GEN_SECTION_INTRODUCTION_PROMT = """\ +- 角色:资深投标文件撰写专家 +- 任务:为章节撰写简短开篇引言(100~200字),直接点明本章的核心做法或服务重点 +- 使用"我方"自称,禁止套话,禁止复述招标要求,禁止重写项目背景,纯文本输出 +- 若本章内容不需要引言可直接输出空白 + +- 项目概要(仅供参考,禁止复述): +{summary} + +- 技术标书目录: +{outline} + +- 章节标题: +{title}\ +""" + + +# ── 对外接口函数 ──────────────────────────────────────────────────────────── + +def get_project_summary_prompt(bid_document: str) -> str: + return PROJECT_SUMMARY.replace('{bid_document}', bid_document) + + +def get_rating_requirements_prompt(bid_document: str) -> str: + return RATING_REQUIREMENTS.replace('{bid_document}', bid_document) + + +def get_rating_json_prompt(tech_rating: str) -> str: + return RATING_JSON.replace('{tech_rating}', tech_rating) + + +def get_outlines_prompt(document_text: str) -> str: + return OUTLINES.replace('{document_text}', document_text) + + +def get_outlines_with_rating_prompt(summary: str, rating: str) -> str: + return OUTLINES_WITH_RATING.replace('{summary}', summary).replace('{rating}', rating) + + +def get_chapter_outline_prompt(summary: str, chapter: str, score: str) -> str: + return CHAPTER_OUTLINE.replace('{summary}', summary).replace('{chapter}', chapter).replace('{score}', score) + + +BOQ_SUMMARY = """\ +- 角色:工程量清单分析专家 + +- 任务:从以下工程量清单数据中提取关键工程信息,生成结构化摘要,供技术标书章节写作使用。 + +- 提取重点: + 1. 主要分部分项工程类别(土建、安装、装饰、市政、绿化等) + 2. 每类工程的核心工程内容与数量(保留单位和数量值) + 3. 主要材料、设备的规格和数量 + 4. 关键施工工艺或特殊要求(如有) + +- 输出格式: + - 按工程类别分段输出,每类列举3-8个代表性工程量项 + - 保留量化数据(数量+单位),例如:"混凝土浇筑 C30 约 800m³" + - 突出与技术标书密切相关的工程内容 + - 不输出单价、金额、合计等商务数据 + - 总字数控制在 600-1200 字 + +- 工程量清单原始文本如下: +{boq_text} + +- 以下为本地规则解析得到的结构化清单附录(分部、编码、名称、单位、工程量);若为空则仅依据上文原始文本: +{boq_structured} +""" + + +def get_boq_summary_prompt(boq_text: str, boq_structured: str = '') -> str: + return ( + BOQ_SUMMARY.replace('{boq_text}', boq_text) + .replace('{boq_structured}', boq_structured or '(无本地结构化附录)') + ) + + +def get_section_detail_prompt(summary: str, outline: str, title: str, + word_count_spec: str = '', + boq_summary: str = '', + tender_kind: str = 'engineering') -> str: + """章节正文提示词。按 tender_kind 选用工程/服务/货物模板(见 utils.tender_kind_sections)。""" + from utils.tender_kind_sections import build_section_detail_prompt, normalize_tender_kind + + if not word_count_spec: + word_count_spec = ( + '- 一般小节:不少于 2000 字;核心技术/重点评分章节:不少于 4000 字\n' + '- 字数须由实质方案内容支撑,禁止用重复项目背景或复述招标要求凑字数\n' + '- 有实质细节的展开写,原则性描述可简洁处理,不强求堆砌篇幅' + ) + return build_section_detail_prompt( + normalize_tender_kind(tender_kind), + summary, + outline, + title, + word_count_spec, + boq_summary, + ) + + +def get_section_intro_prompt(summary: str, outline: str, title: str) -> str: + if not GEN_SECTION_INTRODUCTION_PROMT: + return '' + return GEN_SECTION_INTRODUCTION_PROMT.replace('{summary}', summary).replace('{outline}', outline).replace('{title}', title) + + +def get_figure_addon() -> str: + """启用"图"模式时,追加到章节生成提示词末尾的图示生成规范""" + return """ + +【图示生成规范(必须遵守)】 +在正文适当位置根据本章节具体内容自动插入图示,图示内容必须与所写章节紧密对应,严禁套用与本章无关的通用模板。 + +▌标记格式(不得修改括号和斜杠,标题须具体反映图示内容): +[FIGURE:具体图示标题] +图示内容(用文字、ASCII 符号绘制) +[/FIGURE] + +▌四类触发场景及示例: + +① 组织机构类(涉及管理架构、项目班组、质量/安全/监测机构等)→ 树形图 +[FIGURE:本项目质量管理组织架构图] +项目经理 +├── 技术负责人 ──→ 专职质检员(2人)、测量员(2人) +├── 施工队长 ──→ 土建作业班(8人)、安装班(4人) +└── 安全负责人 ──→ 专职安全员(1人)、消防员(1人) +[/FIGURE] + +② 流程类(涉及施工工序、管理流程、验收程序、应急响应等)→ 流程图 +[FIGURE:监测数据处理与预警响应流程图] +现场采集 ──→ 质检复核 ──→ 数据入库 + ↓ 超阈值 + 预警分级判断 + ↓ 黄色预警 ↓ 红色预警 + 加密监测频次 立即暂停施工 + 应急响应 + ↓ 恢复正常 + 出具监测日报 ──→ 提交建设单位 +[/FIGURE] + +③ 进度计划类(涉及工期安排、里程碑节点、施工阶段等)→ 横道进度图 +[FIGURE:本项目施工进度计划示意图] +第 1- 2 周 ██ 施工准备(人员进场、测量放线、物资备货) +第 3- 6 周 ████ 土方开挖及基础处理 +第 7-11 周 ████████ 主体结构施工 +第12-14 周 ██████ 机电安装及调试 +第15-16 周 ████ 装饰收尾及自检 +第17 周 ██ 竣工验收及资料移交 +[/FIGURE] + +④ 平面布置类(涉及施工现场、监测点位、管线布置等)→ 示意平面图 +[FIGURE:施工现场平面布置示意图] +┌───────────────────────────────────────────┐ +│ [出入口/门卫] [材料堆场] [钢筋加工棚] │ +│ │ +│ [主施工区 A 段] [主施工区 B 段] │ +│ │ +│ [办公/会议室] [宿舍区] [设备停放场地] │ +└───────────────────────────────────────────┘ +[/FIGURE] + +▌执行要求: +- 每章节最多插入 2~3 个图示,按需插入,勿为凑数而强行添加 +- 图示标题须具体,如"本项目安全管理组织架构图"而非"组织架构图" +- 每个图示前后各须有至少一段正文说明,不得孤立出现 +- 图示中的岗位、人数、节点须结合本章节正文内容填写,不得留有"XXX"等占位符""" + + +def get_table_addon() -> str: + """启用"表"模式时,追加到章节生成提示词末尾的表格生成规范""" + return """ + +【表格生成规范(必须遵守)】 +在正文适当位置根据本章节具体内容自动插入表格,表格数据须结合本章节实际内容填写,严禁套用与本章无关的通用模板。 + +▌标记格式(不得修改括号和斜杠,标题须具体反映表格内容): +[TABLE:具体表格标题] +| 列名1 | 列名2 | 列名3 | +|-------|-------|-------| +| 数据1 | 数据2 | 数据3 | +[/TABLE] + +▌六类触发场景及示例: + +① 人员配置类(涉及项目管理团队、专业人员配置等) +[TABLE:本项目主要管理人员配置一览表] +| 序号 | 岗位 | 拟派人数 | 资质要求 | 主要职责 | +|------|------|---------|---------|---------| +| 1 | 项目经理 | 1 | 一级建造师,从业 10 年以上 | 全面统筹项目实施 | +| 2 | 技术负责人 | 1 | 高级工程师,从业 8 年以上 | 技术方案与质量管控 | +| 3 | 安全负责人 | 1 | 注册安全工程师,具备安全 C 证 | 安全生产管理 | +| 4 | 专职质检员 | 2 | 质检员证,从业 5 年以上 | 过程质量检验与记录 | +[/TABLE] + +② 设备投入类(涉及施工机械、监测仪器、工具设备等) +[TABLE:主要施工设备及仪器投入一览表] +| 序号 | 设备名称 | 规格型号 | 数量 | 状态 | 主要用途 | +|------|---------|---------|------|------|---------| +| 1 | 全站仪 | 徕卡 TS16 | 2 台 | 自有 | 平面及高程测量 | +| 2 | 静力水准仪 | BGK-4700 | 8 套 | 自有 | 沉降自动化监测 | +| 3 | 挖掘机 | 卡特 320D | 2 台 | 租赁 | 基坑开挖 | +[/TABLE] + +③ 劳动力计划类(涉及各工种、各阶段人数安排等) +[TABLE:劳动力配置计划表] +| 工种 | 准备阶段(人) | 施工高峰期(人) | 收尾阶段(人) | 备注 | +|------|-------------|---------------|-------------|------| +| 测量工 | 4 | 6 | 2 | 含 1 名高级测量技师 | +| 土建工 | 8 | 20 | 6 | 持证特殊工种优先 | +| 安装工 | 0 | 10 | 4 | 含持证电工、焊工 | +[/TABLE] + +④ 质量/安全检查类(涉及关键工序验收、安全巡检等) +[TABLE:关键工序质量检验项目一览表] +| 序号 | 检验项目 | 检验方法 | 检验频率 | 合格标准 | 责任人 | +|------|---------|---------|---------|---------|------| +| 1 | 基础轴线偏差 | 全站仪复测 | 每道工序 | ≤±5mm | 测量员 | +| 2 | 混凝土强度 | 试块取样 | 每 50m³ | ≥C30 | 质检员 | +[/TABLE] + +⑤ 材料供应类(涉及主要材料规格、用量计划等) +[TABLE:主要材料供应计划表] +| 序号 | 材料名称 | 规格 | 计划用量 | 供应商 | 进场时间 | +|------|---------|------|---------|------|---------| +| 1 | 商品混凝土 | C30 | 约 800m³ | 本地搅拌站 | 第 5 周 | +| 2 | 钢筋 | HRB400Φ16-25 | 约 60t | 资质合规厂商 | 第 4 周 | +[/TABLE] + +⑥ 风险/应急类(涉及风险识别、应急预案等) +[TABLE:主要施工风险及应对措施一览表] +| 风险类型 | 诱因 | 等级 | 预防措施 | 应急响应 | +|---------|------|------|---------|---------| +| 基坑坍塌 | 降雨渗水 | 高 | 坡面防护+排水沟 | 立即撤场+加固 | +| 管线破坏 | 机械误挖 | 中 | 人工开挖保护区 | 停工+抢修 | +[/TABLE] + +▌执行要求: +- 表格数据须根据本章节正文内容填写,不得使用"XXX""待定"等占位符 +- 表格列数控制在 4~6 列,行数视内容而定,不强求凑满 +- 每张表格前后各须有至少一段正文说明 +- 每章节最多插入 2~3 张表格,按需插入""" diff --git a/utils/settings.py b/utils/settings.py new file mode 100644 index 0000000..5ece172 --- /dev/null +++ b/utils/settings.py @@ -0,0 +1,127 @@ +""" +配置持久化:将用户在界面中设置的 API Key 等配置保存到 data/settings.json, +服务重启后自动恢复,不再每次重启都丢失 Key。 +""" +import json +import os +import logging + +logger = logging.getLogger(__name__) + +_SETTINGS_PATH: str = '' # 由 app.py 初始化时注入 + + +def init(settings_path: str): + global _SETTINGS_PATH + _SETTINGS_PATH = settings_path + + +def load(cfg) -> None: + """从 settings.json 加载配置,覆盖 config 模块中的默认值""" + if not _SETTINGS_PATH or not os.path.exists(_SETTINGS_PATH): + _apply_env_overrides(cfg) + return + try: + with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + + _apply(cfg, data) + _apply_env_overrides(cfg) + logger.info(f'已从 {_SETTINGS_PATH} 恢复配置,当前 provider={cfg.MODEL_PROVIDER}') + except Exception as e: + logger.warning(f'加载配置文件失败: {e}') + _apply_env_overrides(cfg) + + +_ENV_API_KEYS = ( + ('QWEN_API_KEY', 'QWEN_API_KEY'), + ('OPENAI_API_KEY', 'OPENAI_API_KEY'), + ('DEEPSEEK_API_KEY', 'DEEPSEEK_API_KEY'), + ('DOUBAO_API_KEY', 'DOUBAO_API_KEY'), + ('KIMI_API_KEY', 'KIMI_API_KEY'), +) + + +def _apply_env_overrides(cfg) -> None: + """环境变量中的 API Key 优先于 settings.json(便于 Docker / 本机 .env 注入)。""" + mp = os.environ.get('MODEL_PROVIDER') + if mp and isinstance(mp, str) and mp.strip(): + cfg.MODEL_PROVIDER = mp.strip() + for env_name, attr in _ENV_API_KEYS: + val = os.environ.get(env_name) + if val and isinstance(val, str) and not val.startswith('sk-your'): + setattr(cfg, attr, val.strip()) + + +def save(cfg) -> None: + """将当前 config 模块的关键配置写入 settings.json""" + if not _SETTINGS_PATH: + return + data = { + 'model_provider': cfg.MODEL_PROVIDER, + 'qwen_api_key': cfg.QWEN_API_KEY, + 'qwen_model': cfg.QWEN_MODEL, + 'qwen_base_url': cfg.QWEN_BASE_URL, + 'openai_api_key': cfg.OPENAI_API_KEY, + 'openai_model': cfg.OPENAI_MODEL, + 'openai_base_url': cfg.OPENAI_BASE_URL, + 'deepseek_api_key': cfg.DEEPSEEK_API_KEY, + 'deepseek_model': cfg.DEEPSEEK_MODEL, + 'deepseek_base_url': cfg.DEEPSEEK_BASE_URL, + 'ollama_base_url': cfg.OLLAMA_BASE_URL, + 'ollama_model': cfg.OLLAMA_MODEL, + 'doubao_api_key': cfg.DOUBAO_API_KEY, + 'doubao_model': cfg.DOUBAO_MODEL, + 'doubao_base_url': cfg.DOUBAO_BASE_URL, + 'kimi_api_key': cfg.KIMI_API_KEY, + 'kimi_model': cfg.KIMI_MODEL, + 'kimi_base_url': cfg.KIMI_BASE_URL, + 'max_concurrent': cfg.MAX_CONCURRENT_SECTIONS, + 'content_volume': cfg.CONTENT_VOLUME, + } + try: + os.makedirs(os.path.dirname(_SETTINGS_PATH), exist_ok=True) + with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.warning(f'保存配置文件失败: {e}') + + +def _apply(cfg, data: dict) -> None: + """将 dict 中的值安全地写回 config 模块""" + str_fields = { + 'model_provider': 'MODEL_PROVIDER', + 'qwen_api_key': 'QWEN_API_KEY', + 'qwen_model': 'QWEN_MODEL', + 'qwen_base_url': 'QWEN_BASE_URL', + 'openai_api_key': 'OPENAI_API_KEY', + 'openai_model': 'OPENAI_MODEL', + 'openai_base_url': 'OPENAI_BASE_URL', + 'deepseek_api_key': 'DEEPSEEK_API_KEY', + 'deepseek_model': 'DEEPSEEK_MODEL', + 'deepseek_base_url': 'DEEPSEEK_BASE_URL', + 'ollama_base_url': 'OLLAMA_BASE_URL', + 'ollama_model': 'OLLAMA_MODEL', + 'doubao_api_key': 'DOUBAO_API_KEY', + 'doubao_model': 'DOUBAO_MODEL', + 'doubao_base_url': 'DOUBAO_BASE_URL', + 'kimi_api_key': 'KIMI_API_KEY', + 'kimi_model': 'KIMI_MODEL', + 'kimi_base_url': 'KIMI_BASE_URL', + } + for key, attr in str_fields.items(): + val = data.get(key) + if val and isinstance(val, str): + setattr(cfg, attr, val) + + if 'max_concurrent' in data: + try: + v = int(data['max_concurrent']) + cfg.MAX_CONCURRENT_SECTIONS = max(1, min(v, 20)) + except (ValueError, TypeError): + pass + + valid_volumes = ('concise', 'standard', 'detailed', 'full') + vol = data.get('content_volume') + if vol and vol in valid_volumes: + cfg.CONTENT_VOLUME = vol diff --git a/utils/tender_kind_sections.py b/utils/tender_kind_sections.py new file mode 100644 index 0000000..12875f3 --- /dev/null +++ b/utils/tender_kind_sections.py @@ -0,0 +1,278 @@ +""" +按招标文件类型(工程 / 服务 / 货物)区分的章节正文生成提示词模板。 +与 modules.generator.BID_WRITING_SYSTEM 配合使用;自称以系统铁律为准,统一用「我方」。 +""" +import re +from typing import Optional + +VALID_TENDER_KINDS = frozenset({'engineering', 'service', 'goods'}) + +DEFAULT_WORD_COUNT_SPEC = ( + '- 一般小节:不少于 2000 字;核心技术/重点评分章节:不少于 4000 字\n' + '- 字数须由实质方案内容支撑,禁止用重复项目背景或复述招标要求凑字数\n' + '- 有实质细节的展开写,原则性描述可简洁处理;通过流程、节点、比选、管控展开满足篇幅' +) + +TENDER_KIND_CLASSIFY = """\ +你是一名招标文件分类专家。根据以下招标文件摘录,判断本项目技术标书应采用的「写作模板类型」。 + +只输出以下三个英文单词之一,不要输出任何其他文字、标点、换行或解释: +engineering +service +goods + +含义: +- engineering:工程施工类(建筑、市政、公路、水利、装修、园林、拆除等,以现场施工组织、工艺、机械、进度网络为主) +- service:服务类(咨询、设计、监理、运维、物业、保洁、餐饮配送、培训、安保、技术服务等,以人力/智力交付、流程、SLA 为主) +- goods:货物类(设备、材料、车辆、家具、软硬件供货等,以产品规格、供货、质保、验收为主;含附带安装指导仍以供货为主可归此类) + +判定规则: +若主要为施工安装且涉及土建/结构/施工机械与工期,归为 engineering。 +若主要为服务过程、人员驻场、响应时效与服务质量体系,归为 service。 +若主要为产品技术规格、供货批次、出厂检验与到货验收,归为 goods。 +若施工与供货并重,以现场施工量与工期为主则 engineering,以设备物资交付为主则 goods。 + +【招标文件摘录】 +{excerpt} +""" + + +def get_tender_kind_classify_prompt(excerpt: str) -> str: + return TENDER_KIND_CLASSIFY.replace('{excerpt}', excerpt or '') + + +def parse_tender_kind_response(response: str) -> str: + """从模型返回中解析出 engineering / service / goods,失败则 engineering。""" + if not response: + return 'engineering' + tokens = re.sub(r'[^a-zA-Z]+', ' ', response).lower().split() + for w in tokens: + if w in VALID_TENDER_KINDS: + return w + low = response.lower() + for k in ('engineering', 'service', 'goods'): + if k in low: + return k + return 'engineering' + + +def normalize_tender_kind(kind: Optional[str]) -> str: + k = (kind or '').strip().lower() + return k if k in VALID_TENDER_KINDS else 'engineering' + + +# ── 工程类 ─────────────────────────────────────────────────────────────── + +SECTION_DETAILS_ENGINEERING = """\ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +- 角色:资深工程施工组织设计专家 +- 任务:撰写通用型工程施工组织设计技术章节 + +【核心定位】 +- 通用施工模板,适用于建筑、市政、公路、水利等工程施工类项目 +- 聚焦:施工方案、工艺方法、机械设备、进度计划、质量安全控制 +- 正文为可直接提交的成稿语句:凡招标文件概要或工程量清单摘要已给出的工程量、地质、工期、指标等,可如实融入叙述;未给出的具体数值、型号、台数、吨位等,一律用通顺的中文概括表达(如"相应规格""与进度及作业面相匹配的台套""符合设计及规范要求的能级"),不得使用方括号或待填项留白 + +【内容特征】 +- 施工工艺描述到"方法层面";可引用规范条文名称或编号(如"应符合JTG/T 3610要求");无依据处不写臆造数字 +- 设备与资源配置:写清设备类别与用途,用"按工况与设计要求选配相应规格与数量""满足流水作业与峰值强度需要"等概括句式,禁止出现"[型号][数量]台"类占位 +- 进度计划使用相对阶段("施工准备期"、"主体施工期")而非具体日期 +- 技术措施可提供多方案比选,用"视地质与水文条件选用适宜工艺"等自然语言衔接现场条件,禁止方括号待填 + +【未定参数的写法(替代一切占位符)】 +- 工程规模与结构:用"本工程相应单体与线路区段""按设计结构形式与跨度条件"等概括,不罗列未提供的具体数字 +- 技术参数:已见于招标/清单的写具体值;未见者写"按设计强度等级与验收标准执行""压实度与分层厚度满足规范及设计要求" +- 机械与劳动力:写"配置满足峰值强度与关键线路需要的机械组合""劳动力按施工阶段动态投入并保持关键岗位持证齐备" +- 时间节点:写"在招标工期内划分准备、主体、收尾阶段并设置可控里程碑",无具体日历则不用臆造周数 + +【行文规范】 +- 自称统一用「我方」,禁用「我们」「本公司」 +- 招标人称「招标方」或「建设单位」 +- 禁止前导句和AI套话(综上所述、高度重视等) +- 列举用(1)(2)(3),禁用"首先其次" +- 纯文本输出,段落间空行分隔 + +【防过拟合约束】 +- 不绑定具体地名与局地气候细节,改为"结合项目环境与季节特点采取针对性措施" +- 不绑定特定施工方法(如不说"必须用旋挖钻",改为"根据地质选用适宜桩基工艺") +- 使用弹性表述:"按设计要求"、"视现场情况"、"符合规范规定" + +【字数要求】 +{word_count_spec} +- 通过展开多方案比选、详细工艺流程、管控节点来满足篇幅 + +【输入】 +- 招标文件概要:{summary} +- 标书目录:{outline} +- 子小节标题:{subsection_title} + +直接输出正文,不含标题和解释。""" + + +# ── 服务类 ─────────────────────────────────────────────────────────────── + +SECTION_DETAILS_SERVICE = """\ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +- 角色:资深服务方案架构师 +- 任务:撰写通用型服务项目实施方案 + +【核心定位】 +- 通用服务模板,适用于咨询服务、运维服务、技术服务、物业管理、培训服务等 +- 聚焦:服务方案、实施流程、人员配置、质量保障、响应机制、服务标准 +- 严禁出现工程施工技术参数(如混凝土标号、压实度等) +- 正文为成稿:招标/采购文件已载明的服务范围、人数、响应时限、到场要求等可如实写入;未载明的不得用方括号待填,改用"按采购文件与服务等级要求配置""满足驻场与高峰时段人力需要""建立分级响应与升级机制"等概括表述写清含义 + +【内容特征】 +- 服务流程:按"接收需求→分析评估→方案制定→实施执行→验收交付→持续改进"框架展开 +- 人员配置:强调专业资质与岗位角色齐全,用"配备满足本项目服务范围与关键岗位持证要求的人员力量""项目经理及骨干具备相应执业或认证资格"等完整句子,禁止"[资质][岗位][数量]名"式占位 +- 质量保障:使用服务体系标准(如ISO 9001、ITIL、ITSS)而非工程规范 +- 响应机制:写清"受理—分派—处理—回访/关闭"闭环;时限已见于招标文件的写具体值,未见者写"按招标文件及行业通行服务等级划分响应与处理时限,并设置升级与应急通道" +- 服务标准:可引用SLA框架,用自然语言描述指标层级与考核方式,禁止用方括号代替指标 + +【未定参数的写法】 +- 服务范围与对象:用"采购文件约定的服务内容与交付边界""服务对象规模与业务场景按项目实际确定"等概括 +- 人员与资源:用"与峰值并发与服务等级相匹配的人力与工具配置" +- 场地与备件:用"按需设置服务场所与备件储备,保障连续性与可用性目标" + +【行文规范】 +- 自称统一用「我方」,禁用「我们」「本公司」 +- 招标人称「招标方」「采购人」或「甲方」 +- 禁止前导句和AI套话 +- 列举用(1)(2)(3),禁用"首先其次" +- 纯文本输出,段落间空行分隔 +- 强调"服务承诺"与"保障措施"的可执行性,避免空泛 + +【防过拟合约束】 +- 不预设具体行业细节(如不说"针对医院HIS系统",改为"针对采购人业务系统与数据环境") +- 服务方案提供"标准模块+可选配置"结构("基础服务包包含...,增值服务可选...") +- 使用"结合采购人行业特点与监管要求""参照同类项目成熟实践"等弹性表述 + +【内容禁区】 +- 禁止出现:施工工艺、材料设备技术参数、工程量计算、施工机械配置 +- 禁止出现:建筑结构、土木工程技术措施 + +【字数要求】 +{word_count_spec} +- 通过详细描述服务流程节点、人员职责分工、质量检查点、应急预案来满足篇幅 + +【输入】 +- 招标文件概要:{summary} +- 标书目录:{outline} +- 子小节标题:{subsection_title} + +直接输出正文,不含标题和解释。""" + + +# ── 货物类 ─────────────────────────────────────────────────────────────── + +SECTION_DETAILS_GOODS = """\ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +- 角色:资深供货方案技术专家 +- 任务:撰写通用型货物采购项目技术响应方案 + +【核心定位】 +- 通用供货模板,适用于设备采购、材料供应、系统集成、软件采购等 +- 聚焦:产品技术规格、供货方案、质量保证、安装调试(如有)、售后服务 +- 正文为成稿:采购文件、技术规范书或清单中已列明的型号、数量、指标、交货期、质保期等可如实响应;未列明的不得臆造优于招标的数字,亦不得用方括号待填;用"不低于采购文件对应条款""满足招标文件列明的性能与符合性要求""供货批次与到货节奏与现场安装计划相衔接"等概括语言写全句 + +【内容特征】 +- 技术规格:按"指标项—符合性说明"展开;已给出阈值的照写;未给出的写"满足招标文件技术指标与检测方法要求""与同类应用场景主流水平相当且不降低实质性响应" +- 产品描述:强调功能特性、可靠性与标准符合性,避免绑定特定品牌(除非招标文件指定) +- 供货方案:分阶段描述(签约后组织生产或备货、出厂检验、运输与到货验收);具体天数仅在有依据时写出,否则用"按合同与采购文件约定的供货周期执行" +- 质量保障:强调"出厂检验+第三方检测(如要求)+质保期服务"分层体系 +- 售后服务:写清质保责任边界、备件与技术支持渠道;时长以招标为准,无则写"按采购文件及国家相关规定执行" + +【未定参数的写法】 +- 性能与容量:用"满足采购文件规定的处理能力/精度/兼容性等关键指标" +- 数量与批次:用"与合同清单及现场需求匹配的供货批次与配套件配置" +- 服务时效:用"建立可追踪的报修、响应与闭环机制,时限不低于采购文件要求" + +【行文规范】 +- 自称统一用「我方」,禁用「我们」「本公司」 +- 招标人称「招标方」「采购人」或「甲方」 +- 禁止前导句和AI套话 +- 列举用(1)(2)(3),禁用"首先其次" +- 纯文本输出,段落间空行分隔 +- 技术描述客观准确,避免夸大(不用"最先进"、"行业第一",改用"符合国家标准或采购文件引用标准的要求""满足招标文件实质性条款") + +【防过拟合约束】 +- 不绑定特定品牌(如不说"采用华为服务器",改为"提供满足采购文件性能与安全要求的服务器设备") +- 无具体数值依据时,不写虚构的"≥某数值",改为对符合性与可检测性的承诺 +- 供货方案考虑多种交付场景(国内供货、进口设备、定制生产等)时,用自然语言比较路径优劣与适用条件 + +【内容禁区】 +- 禁止出现:施工组织、安装工艺(除非含安装服务)、土建工程、人员现场施工配置 +- 禁止出现:工程管理流程(如施工进度网络图) + +【字数要求】 +{word_count_spec} +- 通过详细展开技术参数说明、供货流程节点、质量检验程序、售后服务细则来满足篇幅 + +【输入】 +- 招标文件概要:{summary} +- 标书目录:{outline} +- 子小节标题:{subsection_title} + +直接输出正文,不含标题和解释。""" + + +def build_section_detail_prompt( + kind: str, + summary: str, + outline: str, + title: str, + word_count_spec: str = '', + boq_summary: str = '', +) -> str: + k = normalize_tender_kind(kind) + if k == 'service': + base = SECTION_DETAILS_SERVICE + elif k == 'goods': + base = SECTION_DETAILS_GOODS + else: + base = SECTION_DETAILS_ENGINEERING + + wc = word_count_spec.strip() or DEFAULT_WORD_COUNT_SPEC + text = base.format( + word_count_spec=wc, + summary=summary or '(未提供)', + outline=outline or '(未提供)', + subsection_title=title or '', + ) + text += ( + '\n\n【须同步遵守的全局写作禁忌】' + '禁止复述招标要求后再作答;禁止各章重复工程量数字与项目背景;' + '禁止无依据将参数写成优于招标文件;字数不得仅靠套话堆砌;' + '禁止使用方括号、「待填」「TBD」等表示未完稿字段(如[型号][数量][数值]);' + '未定信息须写成通顺的概括性中文整句。' + '若本任务提示词末尾另有「图示/表格」专用输出规范,其中的结构化标记按该规范执行,' + '不视为待填占位。' + ) + + if boq_summary.strip(): + text += ( + '\n\n- 工程量清单关键信息(写作时按需引用清单中已有数量与单位,勿无故复读;' + '清单未列明的分项用概括性施工组织语言描述,禁止使用方括号待填项):\n' + + boq_summary.strip() + ) + return text + + +# 对话模式:按类型追加的系统说明片段(与 app.py 中基础说明拼接) +CHAT_KIND_INSTRUCTION = { + 'engineering': ( + '\n【本模板类型:工程施工】' + '侧重施工组织、工艺与质量安全;未在招标文件或清单中出现的具体型号、台数、吨位等' + '用概括性中文表述写清,禁止使用方括号待填;勿虚构优于招标的规格。' + ), + 'service': ( + '\n【本模板类型:服务】' + '侧重服务流程、人员与SLA;人数、时限等以招标/采购文件为准,无则概括表述,禁止方括号待填;' + '禁止大段写混凝土标号、压实度、施工机械等工程参数。' + ), + 'goods': ( + '\n【本模板类型:货物供货】' + '侧重规格、供货、检验与质保;指标与交期以采购文件为准,无则概括表述,禁止方括号待填;' + '禁止写施工组织与土建;勿绑定未指定的品牌。' + ), +}