""" 标伙伴 - 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'), ("ALTER TABLE tender_data ADD COLUMN target_pages INTEGER DEFAULT 0", 'tender_data.target_pages'), ] 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, COALESCE(td.target_pages, 0) 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', 'target_pages': int(row[21] or 0), }) # ═══════════════════════════════════════════════════════════════════════════ # 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 'target_pages' in data: try: fields['target_pages'] = max(0, int(data['target_pages'])) except (TypeError, ValueError): return jsonify({'error': 'target_pages 须为非负整数'}), 400 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)) conn = get_db() cur = conn.cursor() cur.execute( ''' SELECT p.outline_status, COALESCE(td.outline, '') FROM projects p LEFT JOIN tender_data td ON td.project_id = p.id WHERE p.id = ? ''', (project_id,), ) row = cur.fetchone() if not row: conn.close() return jsonify({'success': False, 'error': '项目不存在'}), 404 ostatus, outline_text = (row[0] or 'none', row[1] or '') if ostatus == 'outline_generating': conn.close() return jsonify({'success': True, 'message': '大纲正在生成中,请稍候'}), 200 if not force and outline_text.strip(): conn.close() return jsonify({ 'success': False, 'error': '当前项目已有大纲,重新生成会覆盖现有大纲。请确认后以 force=true 再次请求。' }), 409 # 立即落库,避免仅依赖后台线程的首条 SQL 时产生竞态(重复点击、轮询、刷新时状态不一致) cur.execute( "UPDATE projects SET outline_status=?, outline_error=?, updated_at=? WHERE id=?", ('outline_generating', '', datetime.now(), project_id), ) conn.commit() conn.close() 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, ) from utils import volume_chapters as vol_ch data = request.get_json() or {} outline = data.get('outline', '') if not outline.strip(): return jsonify({'success': False, 'error': '大纲内容不能为空'}), 400 no_subchapter_limit = data.get('no_subchapter_limit') in (True, 1, '1', 'true', 'True') if 'target_pages' in data: try: request_pages = max(0, int(data['target_pages'])) except (TypeError, ValueError): request_pages = None else: request_pages = None conn = get_db() cur = conn.cursor() cur.execute( "SELECT summary, rating_requirements, COALESCE(target_pages, 0) 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 '' db_tp = int(row[2] or 0) if row else 0 cfg_tp = int(getattr(config, 'TARGET_PAGES', 0) or 0) effective_target_pages = vol_ch.resolve_expand_target_pages( request_pages, no_subchapter_limit, db_tp, cfg_tp ) try: expanded_outline = expand_outline( outline, summary, rating_requirements, project_id, effective_target_pages ) _, 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, 'used_target_pages': effective_target_pages, 'no_subchapter_limit': no_subchapter_limit, }) 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) @app.route('/api/projects//check-dark-bid-format', methods=['POST']) def api_check_dark_bid_format(project_id): """技术暗标 HTML 格式清标(不调用 AI)。请求体 JSON:{"html": "..."}""" conn = get_db() cur = conn.cursor() cur.execute("SELECT id FROM projects WHERE id=?", (project_id,)) if not cur.fetchone(): conn.close() return jsonify({'error': '项目不存在'}), 404 conn.close() data = request.get_json(silent=True) or {} html = (data.get('html') or '').strip() if not html: return jsonify({'error': '请提供 html 字段(技术暗标完整 HTML 内容)'}), 400 from modules.dark_bid_format_check import check_technical_bid try: result = check_technical_bid(html) return jsonify(result) except Exception as e: logger.exception('技术暗标格式清标失败') return jsonify({'error': str(e)}), 500 # ═══════════════════════════════════════════════════════════════════════════ # 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, 'target_pages': getattr(config, 'TARGET_PAGES', 0), 'page_char_estimate': getattr(config, 'PAGE_CHAR_ESTIMATE', 700), }) @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'] if 'target_pages' in data: try: config.TARGET_PAGES = max(0, int(data['target_pages'])) except (ValueError, TypeError): pass if 'page_char_estimate' in data: try: config.PAGE_CHAR_ESTIMATE = max(300, min(3000, int(data['page_char_estimate']))) except (ValueError, TypeError): pass _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)