From e82de897ac29af8a65bf62f798ae5a8073a7864d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 02:18:26 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- document.py | 1149 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1149 insertions(+) create mode 100644 document.py diff --git a/document.py b/document.py new file mode 100644 index 0000000..0cf0d70 --- /dev/null +++ b/document.py @@ -0,0 +1,1149 @@ +import sys +from pathlib import Path +import asyncio +import uuid + +from docxcompose.composer import Composer +from docx import Document as Document_compose +from docxtpl import DocxTemplate +from typing import List, Optional +import json +import os +import re +from datetime import datetime, date + +from fastapi import HTTPException +from md2docx.converter import MarkdownToDocx +from sqlalchemy import select, func, update, delete +from agents.ai_agents.task_hub.schemas.task_schema import ProjectTaskRecordCreate, TaskTypeEnum, TaskStatusEnum +from agents.ai_agents.task_hub.services.task_service import async_create_or_update_task_record_service +from agents.config.database import AsyncSessionLocal +from agents.interface.service.project_service import async_get_project_by_id_service +from agents.utils.document_parser import DocumentParser + +from agents.ai_agents.document_agent.schemas.document_schema import DocumentGenerationRequest, DocumentCategoryEnum, \ + DocumentSSEData,GenerateCheckRoleRequest +from agents.ai_agents.document_agent.services.document_service import DOCUMENT_TYPES +from agents.interface.schemas.project_file_link_schema import FileLinkSchema, CreateProjectFileLinkSchema +from agents.interface.service.project_file_link_service import async_create_file_link +from agents.models.base.project_model import FileEntity +from agents.utils.minio_client import minio_client +from agents.utils.openai_client import get_open_ai_client +from agents.ai_agents.document_agent.services.code_parser_service import CodeParserService +from agents.utils.run_command import run_command +from agents.ai_agents.prompt_template.models.prompt_template_model import ProjectTemplateEntity +from agents.ai_agents.prompt_template.services.project_template_custom_service import ProjectTemplateCustomService +from agents.ai_agents.test_verification_agent.services.task_service import TaskService +from agents.ai_agents.diagram_agent.utils.drawio_utils import replace_drawio_host + + +_STREAM_END = object() + + +def _next_stream_chunk(iterator): + try: + return next(iterator) + except StopIteration: + return _STREAM_END + + +async def _iterate_chat_completion_stream(openai_client, messages): + """ + The OpenAI wrapper exposes a blocking sync generator. Running next() in a + worker thread keeps concurrent SSE requests from blocking the event loop. + """ + iterator = openai_client.chat_completion_stream([dict(message) for message in messages]) + while True: + chunk = await asyncio.to_thread(_next_stream_chunk, iterator) + if chunk is _STREAM_END: + break + yield chunk + + +class DocumentGenerationService: + """文档生成服务类""" + + @classmethod + def load_template_prompt(cls, file_name: str) -> str: + """ + 加载系统提示词模板 + + :return: 系统提示词模板内容 + """ + template_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "templates", + file_name + ) + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + +async def generate_stream_event_check_role( + request: GenerateCheckRoleRequest, +): + """ + 根据项目文档模板id, + 获取到模板信息, + 调用ai生成校验规则文档 + """ + + + # 调用OpenAI流式生成 + full_content = "" + try: + project_template = await async_get_project_template_info(request.templateId) + project = await async_get_project_by_id_service(project_template.project_id) + content = project_template.content + name = project_template.name + file_name = name + "校验规则" + prompt = load_check_role_prompt(content, name) + messages = [ + {"role": "system", "content": prompt}, + {"role": "user", "content": ""} + ] + + start_event = DocumentSSEData( + eventType="start", + message=f"开始生成{file_name}", + documentName=None + ) + yield f"data: {json.dumps(start_event.to_dict(), ensure_ascii=False)}\n\n" + + openai_client = get_open_ai_client(project.api_key, project.base_url, project.model_name) + async for chunk in _iterate_chat_completion_stream(openai_client, messages): + full_content += chunk + # 发送内容块事件 + content_event = DocumentSSEData( + eventType="document_generating", + message=chunk, + documentName=file_name + ) + yield f"data: {json.dumps(content_event.to_dict(), ensure_ascii=False)}\n\n" + + error_event = DocumentSSEData( + eventType="end", + message="校验规则生成成功", + documentName=file_name + ) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + + + except Exception as e: + error_event = DocumentSSEData( + eventType="error", + message=f"文档生成失败, 异常信息:{str(e)}", + documentName=file_name + ) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + + + +async def generate_stream_event_generator( + request: DocumentGenerationRequest, + # db: Session +): + """ + SSE流式事件生成器 + + :param request: 文档生成请求 + :param db: 数据库会话 + :return: SSE事件流 + """ + + generation_request_id = uuid.uuid4().hex + project = await async_get_project_by_id_service(request.projectId) + if not project: + error_event = DocumentSSEData( + eventType="end", + message="项目不存在", + documentName=None, + documentIndex=0 + ) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + return + # 3. 创建任务记录 + task_record_data = ProjectTaskRecordCreate( + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.RUNNING, + task_log="开始生成文档" + ) + task_record_result = await async_create_or_update_task_record_service(task_record_data) + task_id = task_record_result.data.id + try: + # 发送开始事件 + start_event = DocumentSSEData( + eventType="start", + # message=f"开始生成 {len(request.produceDocumentList)} 个文档", + message=f"开始生成 {len(request.projectTemplateIdList)} 个文档", + documentName=None, + documentIndex=0 + ) + yield f"data: {json.dumps(start_event.to_dict(), ensure_ascii=False)}\n\n" + + # 获取参考文档内容 + reference_content = "" + sys_data = "" + if request.referToDocument is not None and len(request.referToDocument) > 0: + analysis_start_event = DocumentSSEData( + eventType="analysisDocumentStart", + message="开始分析参考文档", + documentName=None, + documentIndex=0 + ) + yield f"data: {json.dumps(analysis_start_event.to_dict(), ensure_ascii=False)}\n\n" + + # 使用非流式版本获取参考文档内容 + reference_content = await async_handle_refer_document(request, project) + # 输出分析过程中的事件(模拟) + analysis_complete_event = DocumentSSEData( + eventType="analysisDocumentEnd", + message="参考文档分析完成", + documentName=None, + documentIndex=0 + ) + yield f"data: {json.dumps(analysis_complete_event.to_dict(), ensure_ascii=False)}\n\n" + + # 逐个生成文档 + save_document_list: List[FileLinkSchema] = [] + for index, project_template_id in enumerate(request.projectTemplateIdList, 1): + # 获取文档模板信息 + project_template = await async_get_project_template_info(project_template_id) + doc_name = project_template.name + check_doc_name = doc_name + "校验报告" + project_check_role = project_template.check_role + project_template_content = project_template.content + custom_template_content = await ProjectTemplateCustomService.get_latest_custom_content( + request.projectId, + project_template_id + ) + if custom_template_content is not None: + project_template_content = custom_template_content + category = project_template.category + # 发送文档开始事件 + doc_start_event = DocumentSSEData( + eventType="document_start", + message=f"开始生成文档: {doc_name}", + documentName=doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(doc_start_event.to_dict(), ensure_ascii=False)}\n\n" + # 获取编写提示词模板 + system_prompt = load_prompt(request, sys_data, doc_name, reference_content, project_template_content) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"{request.userInput}"} + ] + # 如果选择了仓库信息,可以将仓库先解析,之后再填充至仓库的解析结果 + if request.repoInfo is not None: + if request.repoInfo.username and request.repoInfo.password: + # 在URL中插入用户名和密码 + from urllib.parse import urlparse + parsed = urlparse(request.repoInfo.url) + auth_url = (f"{parsed.scheme}://{request.repoInfo.username}" + f":{request.repoInfo.password}@{parsed.netloc}{parsed.path}") + repo_url = auth_url + coed_parser_service = CodeParserService() + parse_result = coed_parser_service.parse_code_repository( + repo_url=repo_url, branch=request.repoInfo.branch, use_doxygen=True) + prompt = (coed_parser_service.build_architecture_prompt + (parse_result=parse_result, project_name=project.name)) + messages.append({ + "role": "user", "content": f"工程代码信息如下:{prompt}" + }) + # 调用OpenAI流式生成 + full_content = "" + try: + openai_client = get_open_ai_client(project.api_key, project.base_url, project.model_name) + async for chunk in _iterate_chat_completion_stream(openai_client, messages): + full_content += chunk + # 发送内容块事件 + content_event = DocumentSSEData( + eventType="document_generating", + message=chunk, + documentName=doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(content_event.to_dict(), ensure_ascii=False)}\n\n" + except Exception as e: + error_event = DocumentSSEData( + eventType="error", + message=f"文档生成失败: {doc_name}, 异常信息:{str(e)}", + documentName=doc_name, + documentIndex=index + ) + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.ERROR, + task_log=f"文档生成失败: {doc_name}, 异常信息:{str(e)}" + ) + await async_create_or_update_task_record_service(update_task_data) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + continue + + # 开始将文档转换为md格式 + convert_succeeded = False + convert_failed = False + for event in handle_conver_file( + doc_name, + full_content, + project, + save_document_list, + request, + category, + generation_request_id + ): + if event.eventType == "convert_complete": + convert_succeeded = True + elif event.eventType == "convert_error": + convert_failed = True + yield f"data: {json.dumps(event.to_dict(), ensure_ascii=False)}\n\n" + + if convert_failed or not convert_succeeded: + error_message = f"文档转换失败: {doc_name}" + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.ERROR, + task_log=error_message + ) + await async_create_or_update_task_record_service(update_task_data) + error_event = DocumentSSEData( + eventType="error", + message=error_message, + documentName=doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + continue + + # 发送文档完成事件 + doc_complete_event = DocumentSSEData( + eventType="document_complete", + message=f"文档生成完成: {doc_name}", + documentName=doc_name, + documentIndex=index + ) + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.PENDING, + task_log=f"文档生成完成: {doc_name}" + ) + await async_create_or_update_task_record_service(update_task_data) + yield f"data: {json.dumps(doc_complete_event.to_dict(), ensure_ascii=False)}\n\n" + + # 文档校验,生成校验报告 + if request.check: + if not project_check_role: + check_start_error = DocumentSSEData( + eventType="check_start_error", + message=f"文档校验规则不存在: {doc_name}", + documentName=doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(check_start_error.to_dict(), ensure_ascii=False)}\n\n" + continue + + check_start = DocumentSSEData( + eventType="check_start", + message=f"开始生成校验报告: {check_doc_name}", + documentName=doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(check_start.to_dict(), ensure_ascii=False)}\n\n" + + check_prompt = load_check_prompt(full_content, project_check_role, doc_name) + check_messages = [ + {"role": "system", "content": check_prompt}, + {"role": "user", "content": "请按照规则生成校验报告"} + ] + check_full_content = "" + try: + openai_client = get_open_ai_client(project.api_key, project.base_url, project.model_name) + async for chunk in _iterate_chat_completion_stream(openai_client, check_messages): + check_full_content += chunk + # 发送内容块事件 + content_event = DocumentSSEData( + eventType="document_checking", + message=chunk, + documentName=check_doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(content_event.to_dict(), ensure_ascii=False)}\n\n" + except Exception as e: + error_event = DocumentSSEData( + eventType="error", + message=f"校验报告生成失败: {check_doc_name}, 异常信息:{str(e)}", + documentName=doc_name, + documentIndex=index + ) + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.ERROR, + task_log=f"校验报告生成失败: {check_doc_name}, 异常信息:{str(e)}" + ) + await async_create_or_update_task_record_service(update_task_data) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + continue + check_convert_succeeded = False + check_convert_failed = False + for event in handle_conver_file( + check_doc_name, + check_full_content, + project, + save_document_list, + request, + category, + generation_request_id + ): + if event.eventType == "convert_complete": + check_convert_succeeded = True + elif event.eventType == "convert_error": + check_convert_failed = True + yield f"data: {json.dumps(event.to_dict(), ensure_ascii=False)}\n\n" + if check_convert_failed or not check_convert_succeeded: + error_message = f"校验报告转换失败: {check_doc_name}" + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.ERROR, + task_log=error_message + ) + await async_create_or_update_task_record_service(update_task_data) + error_event = DocumentSSEData( + eventType="error", + message=error_message, + documentName=check_doc_name, + documentIndex=index + ) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + continue + # 发送文档完成事件 + doc_complete_event = DocumentSSEData( + eventType="check_document_complete", + message=f"校验报告生成完成: {check_doc_name}", + documentName=check_doc_name, + documentIndex=index + ) + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.PENDING, + task_log=f"校验报告生成完成: {check_doc_name}" + ) + await async_create_or_update_task_record_service(update_task_data) + yield f"data: {json.dumps(doc_complete_event.to_dict(), ensure_ascii=False)}\n\n" + + if save_document_list is not None and len(save_document_list) > 0: + try: + file_link_schema = CreateProjectFileLinkSchema( + project_id=request.projectId, + file_list=save_document_list, + # type=None + ) + # 创建附件关联 + links = await async_create_file_link(file_link_schema) + + # 添加任务文件关联记录 + if request.taskId and links: + file_ids = [link.file_id for link in links] + await TaskService.async_add_task_file_links(request.taskId, file_ids) + except Exception as e: + error_event = DocumentSSEData( + eventType="error", + message=f"文件上传失败: {e}", + documentName=None, + documentIndex=0 + ) + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.ERROR, + task_log=f"文件上传失败: {e}" + ) + await async_create_or_update_task_record_service(update_task_data) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + # 发送结束事件 + end_event = DocumentSSEData( + eventType="end", + message="", + documentName=None, + documentIndex=len(request.produceDocumentList) + ) + update_task_data = ProjectTaskRecordCreate( + id=task_id, + project_id=request.projectId, + task_type=TaskTypeEnum.GENERATE_DOCUMENT, + task_status=TaskStatusEnum.COMPLETED, + task_log=f"文档生成成功" + ) + await async_create_or_update_task_record_service(update_task_data) + yield f"data: {json.dumps(end_event.to_dict(), ensure_ascii=False)}\n\n" + + except Exception as e: + error_event = DocumentSSEData( + eventType="error", + message=f"生成过程出错: {str(e)}", + documentName=None, + documentIndex=0 + ) + yield f"data: {json.dumps(error_event.to_dict(), ensure_ascii=False)}\n\n" + end_event = DocumentSSEData( + eventType="end", + message="", + documentName=None, + documentIndex=len(request.produceDocumentList) + ) + yield f"data: {json.dumps(end_event.to_dict(), ensure_ascii=False)}\n\n" + + +async def async_get_project_template_info(project_template_id) -> ProjectTemplateEntity: + async with AsyncSessionLocal() as db: + result = await db.execute( + select(ProjectTemplateEntity).where(ProjectTemplateEntity.id == project_template_id) + ) + + project_template = result.scalar_one_or_none() + if not project_template: + raise HTTPException(status_code=404, detail=f"项目模板不存在: {project_template_id}") + if not project_template.content: + raise HTTPException(status_code=404, detail=f"项目模板内容不存在: {project_template.name}") + return project_template + + +def load_prompt(request, sys_data, doc_name, reference_content, project_template_content) -> str: + document_template_prompt = "" + # project_template_content 为空就读内置文件 + if project_template_content is None or project_template_content == '': + document_template_prompt = DocumentGenerationService.load_template_prompt(file_name=doc_name + ".md") + else: + document_template_prompt = project_template_content + # 构建消息 + system_prompt = DocumentGenerationService.load_template_prompt(file_name="document_system.md") + system_prompt = system_prompt.replace("{SYSTEM_DATA}", sys_data) + system_prompt = system_prompt.replace("{USER_SUPPLEMENT_QUANTITY}", request.userInput) + system_prompt = system_prompt.replace("{WRITE_TEMPLATE}", document_template_prompt) + system_prompt = system_prompt.replace("{REFERENCE_CONTENT}", reference_content) + system_prompt = system_prompt.replace("{USER_EXTRA_CONSTRAINT}", "无额外约束") + system_prompt = system_prompt.replace("{OTHER_WRITE_CONSTRAINT}", f"国军标选项: {request.GJBSelectOption}") + system_prompt = system_prompt.replace("{CURRENT_DATE}", f'{date.today().strftime("%Y年%m月%d日")}') + return system_prompt + + +def load_check_prompt(document_content, check_role_connect, doc_name) -> str: + """构造校验文档的提示词信息""" + check_prompt = DocumentGenerationService.load_template_prompt(file_name="document_check.md") + check_prompt = check_prompt.replace("{AI_GENERATED_DOCUMENT}", document_content) + check_prompt = check_prompt.replace("{CHECK_DOCUMENT}", check_role_connect) + check_prompt = check_prompt.replace("{WRITE_TEMPLATE}", doc_name) + return check_prompt + +def load_check_role_prompt(document_content, document_name) -> str: + """获取生成校验规则文档的提示词信息""" + check_prompt = DocumentGenerationService.load_template_prompt(file_name="generate_check_role.md") + check_prompt = check_prompt.replace("{TEMPLATE_DOCUMENT}", document_content) + check_prompt = check_prompt.replace("{DOCUMENT_NAME}", document_name) + return check_prompt + + +async def handle_refer_document(request, project) -> str: + """处理参考文档,返回分析后的内容(不流式输出)""" + content_map = {} + openai_client = get_open_ai_client(project.api_key, project.base_url, project.model_name) + async with AsyncSessionLocal() as db: + result = await db.execute( + select(FileEntity).where(FileEntity.id.in_(request.referToDocument)) + ) + file_list = result.scalars().all() + + for doc in file_list: + if doc: + # 从Minio下载并解析文档 + try: + text_content = DocumentParser.parse_all_document(doc, openai_client=openai_client) + content_map[doc.name] = text_content + except Exception as e: + raise Exception("文档分析失败", str(e)) + return json.dumps(content_map, ensure_ascii=False) + # 组装提示词 + # remote_prompt = DocumentGenerationService.load_template_prompt(file_name="remote_analysis.md") + # remote_prompt = remote_prompt.replace("{ANALYSIS_CONTENT}", json.dumps(content_map)) + # + # messages = [ + # {"role": "system", "content": remote_prompt}, + # {"role": "user", "content": "请你帮我分析以下内容"} + # ] + # try: + # # 使用非流式方式获取完整内容 + # full_content = openai_client.chat_completion(messages) + # return full_content + # except Exception as e: + # error_msg = f"生成文档失败: {str(e)}" + # raise Exception("文档分析失败:", error_msg) + + +async def async_handle_refer_document(request, project) -> str: + content_map = {} + openai_client = get_open_ai_client(project.api_key, project.base_url, project.model_name) + async with AsyncSessionLocal() as db: + result = await db.execute( + select(FileEntity).where(FileEntity.id.in_(request.referToDocument)) + ) + file_list = result.scalars().all() + + for doc in file_list: + if doc: + try: + text_content = DocumentParser.parse_all_document(doc, openai_client=openai_client) + content_map[doc.name] = text_content + except Exception as e: + raise Exception("文档分析失败", str(e)) + return json.dumps(content_map, ensure_ascii=False) + + +# draw.io CLI 路径解析 +def _resolve_drawio_cli() -> Optional[str]: + """解析 Draw.io 可执行文件路径(不含 xvfb-run)。""" + import logging + import platform + import shutil + + logger = logging.getLogger(__name__) + + # 1. 环境变量 DRAWIO_CLI_PATH + env_path = os.getenv("DRAWIO_CLI_PATH") + if env_path and os.path.isfile(env_path): + logger.info(f"使用环境变量 DRAWIO_CLI_PATH: {env_path}") + return env_path + + # 2. 项目内默认路径(与 article_agent 保持一致) + default = os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "..", "..", "..", "..", "soft", "drawio", "draw.io", "draw.io.exe", + )) + if os.path.isfile(default): + logger.info(f"使用项目默认路径: {default}") + return default + + # 3. tool 目录下的 drawio.exe(兼容旧配置) + tool_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tool/drawio.exe") + if os.path.isfile(tool_path): + logger.info(f"使用 tool 目录: {tool_path}") + return tool_path + + # 4. PATH 查找 + for name in ("draw.io.exe", "drawio", "draw.io"): + found = shutil.which(name) + if found: + logger.info(f"使用系统 PATH: {found}") + return found + + logger.warning("Draw.io 未找到,请安装或设置 DRAWIO_CLI_PATH 环境变量") + return None + + +DRAWIO_EXE_PATH = _resolve_drawio_cli() + + +def extract_drawio_blocks(content: str) -> list: + """ + 从Markdown内容中提取draw.io XML代码块 + + :param content: Markdown内容 + :return: draw.io XML列表 + """ + import re + pattern = r'```(?:drawio|xml)\n(.*?)\n```' + matches = re.findall(pattern, content, re.DOTALL) + # 替换每个 XML 块中的 host + replaced_matches = [replace_drawio_host(match) for match in matches] + return replaced_matches + + +def convert_drawio_to_png(drawio_xml: str, output_path: str, output_dir) -> bool: + """ + 使用draw.io将XML转换为PNG + + :param output_dir: 输出目录 + :param drawio_xml: draw.io XML内容 + :param output_path: 输出PNG文件路径 + :return: 是否转换成功 + """ + import tempfile + import logging + import re + import platform + import shutil + logger = logging.getLogger(__name__) + + # 优化 drawio XML 中的画布大小,确保适配内容 + page_width_match = re.search(r'pageWidth="(\d+)"', drawio_xml) + page_height_match = re.search(r'pageHeight="(\d+)"', drawio_xml) + + if page_width_match and page_height_match: + page_width = int(page_width_match.group(1)) + page_height = int(page_height_match.group(1)) + # 如果画布过大(超过2000),调整为更合适的尺寸 + if page_width > 1200 or page_height > 1000: + new_width = min(800, page_width) + new_height = min(600, page_height) + drawio_xml = drawio_xml.replace( + f'pageWidth="{page_width}"', f'pageWidth="{new_width}"' + ).replace( + f'pageHeight="{page_height}"', f'pageHeight="{new_height}"' + ) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.drawio', delete=False, encoding='utf-8') as f: + f.write(drawio_xml) + xml_file = f.name + + try: + # 检查draw.io命令是否存在 + if os.path.exists(DRAWIO_EXE_PATH): + drawio_cmd = DRAWIO_EXE_PATH + logger.info(f"使用内置draw.io: {DRAWIO_EXE_PATH}") + else: + drawio_cmd = shutil.which("draw.io") or shutil.which("drawio") + if not drawio_cmd: + logger.error("draw.io命令未找到,请确认已安装并添加到PATH") + return False + logger.info(f"使用系统draw.io: {drawio_cmd}") + + if platform.system() == "Linux": + drawio_cmd = f"xvfb-run -a {drawio_cmd}" + + command = ( + f'{drawio_cmd} -x -f png ' + f'-o "{output_path}" ' + f'-b 0 ' + f'--crop ' + f'"{xml_file}"' + ) + logger.info(f"执行draw.io转换命令: {command}") + run_command(command, output_dir) + + # 检查输出文件是否生成 + if os.path.exists(output_path): + file_size = os.path.getsize(output_path) + logger.info(f"draw.io转换成功,输出文件: {output_path}, 大小: {file_size} bytes") + return True + else: + logger.error(f"draw.io转换完成但输出文件不存在: {output_path}") + return False + except Exception as e: + logger.error(f"draw.io转换异常: {str(e)}", exc_info=True) + return False + finally: + if os.path.exists(xml_file): + os.unlink(xml_file) + + +def process_drawio_images(content: str, output_dir: str): + """ + 处理Markdown中的draw.io代码块,转换为PNG图片引用 + + :param content: Markdown内容 + :param output_dir: 输出目录 + :return: 处理后的Markdown内容,draw.io块被替换为图片引用 + """ + import re + import logging + logger = logging.getLogger(__name__) + + pattern = r'```(?:drawio|xml)\n(.*?)\n```' + matches = re.findall(pattern, content, re.DOTALL) + total_images = len(matches) + + if total_images == 0: + yield DocumentSSEData( + eventType="convert_processing", + message="无draw.io图片需要处理", + documentName=None, + documentIndex=0 + ) + yield content + return + + yield DocumentSSEData( + eventType="convert_processing", + message=f"开始处理 {total_images} 个draw.io图片", + documentName=None, + documentIndex=0 + ) + + parts = [] + last_end = 0 + image_index = 0 + + for match in re.finditer(pattern, content, re.DOTALL): + parts.append(content[last_end:match.start()]) + last_end = match.end() + drawio_xml = match.group(1).strip() + + # 替换 draw.io XML 中的 host + drawio_xml = replace_drawio_host(drawio_xml) + + image_index += 1 + + yield DocumentSSEData( + eventType="convert_processing", + message=f"处理图片 {image_index}/{total_images}", + documentName=None, + documentIndex=0 + ) + + png_filename = f"diagram_{image_index}.png" + png_path = os.path.join(output_dir, png_filename) + + xml_filename = f"diagram_{image_index}.drawio" + xml_path = os.path.join(output_dir, xml_filename) + + with open(xml_path, 'w', encoding='utf-8') as f: + f.write(drawio_xml) + + success = convert_drawio_to_png(drawio_xml, png_path, output_dir) + if success: + logger.info(f"draw.io图片生成成功: {png_path}") + # 使用相对路径而不是绝对路径,避免转换问题 + parts.append(f"![diagram]({png_filename})") + yield DocumentSSEData( + eventType="convert_processing", + message=f"图片 {image_index}/{total_images} 生成完成", + documentName=None, + documentIndex=0 + ) + else: + logger.warning(f"draw.io图片生成失败: {png_filename},继续处理") + # 即使转换失败,也保留占位符,避免破坏文档结构 + parts.append(f"![diagram_{image_index}](图片转换失败)") + # 注意:这里不发送convert_error事件,因为图片转换失败不应影响整体转换结果 + yield DocumentSSEData( + eventType="convert_processing", + message=f"图片 {image_index}/{total_images} 转换失败(已保留占位符)", + documentName=None, + documentIndex=0 + ) + + parts.append(content[last_end:]) + processed_content = "".join(parts) + + yield DocumentSSEData( + eventType="convert_processing", + message=f"draw.io图片处理完成,共处理 {total_images} 张图片", + documentName=None, + documentIndex=0 + ) + + yield processed_content + + +def sanitize_generated_markdown(content: str) -> str: + """ + Clean model output before Markdown->Word conversion. + + Current fixes: + - drop template artifact lines like ``\3.1 接口标识和接口图`` + - de-duplicate immediately repeated heading text caused by prompt examples + """ + cleaned_lines = [] + previous_non_empty = "" + + for raw_line in content.splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + + if re.match(r"^\\\d+(?:\.\d+)*\s+", stripped): + normalized = stripped[1:].strip() + if normalized == previous_non_empty: + continue + # These are template residue lines, not valid final content. + continue + + if stripped and stripped == previous_non_empty: + continue + + cleaned_lines.append(line) + if stripped: + previous_non_empty = stripped + + return "\n".join(cleaned_lines) + + +if __name__ == '__main__': + base_path = os.path.dirname(os.path.abspath(sys.argv[0])) + if not os.path.exists(f"{base_path}/workspace"): + os.makedirs(f"{base_path}/workspace") + import uuid + current_temp_path = f"{base_path}/workspace/{uuid.uuid4()}" + os.makedirs(current_temp_path) + template_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates/template.docx") + # mermaid_filter_lua_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tool/mermaid-filter.lua") + # 使用库来进行文档转换 + + converter = MarkdownToDocx(template_file, font_name="宋体") + docx_file_path = f"{current_temp_path}/aaa.docx" + with open( + "C:/workspace/ai/DocumentGenerateAgent/agents/ai_agents/workspace/1.md", + 'r', + encoding="UTF-8") as f: + txt = f.read() + + converter.convert(txt, docx_file_path) + # 转换图表的题注。 + from agents.ai_agents.document_agent.utils import docx_util + + docx_util.fix_table_borders(docx_file_path) + docx_util.add_captions(docx_file_path) + + + # for event1 in process_drawio_images(txt, "D:/workspace/agent/DocumentGenerateAgent/agents/workspace/test"): + # if isinstance(event1, str): + # processed_content1 = event1 + # else: + # print(f'{event1}') + # convert_drawio_to_png(txt, + # "D:/workspace/agent/DocumentGenerateAgent/agents/workspace/bcb79d47-4b33-44fd-9aa6-7334ee714f57/2.png", + # "D:/workspace/agent/DocumentGenerateAgent/agents/workspace/bcb79d47-4b33-44fd-9aa6-7334ee714f57") + # with open("D:/workspace/agent/DocumentGenerateAgent/agents/ai_agents/document_agent/templates/txt.txt", 'r', + # encoding="UTF-8") as f: + # txt = f.read() + # process_drawio_images(txt, + # "D:/workspace/agent/DocumentGenerateAgent/agents/workspace/3ce4660d-e4f6-4fd3-a5d4-29fb0dd25a22/output") + + +# 处理基于模板转换的文件 +def convert_template_docx(docx_file_path, project, doc_name, current_temp_path) -> str: + context_data = { + "projectName": project.name, + "name": doc_name, + } + # 加载模板 + template_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates/base_template.docx") + template = DocxTemplate(template_file) + # 执行填充,生成一个填充好的中间文档 + template.render(context_data) + # 保存中间文档,此文档包含了填充后的所有内容 + filled_file = f'{current_temp_path}/filled_temp.docx' + template.save(filled_file) + + # --- 第二阶段:将填充好的文档与其他文档合并 --- + # 创建一个基于填充后文档的合并器 + base_doc = Document_compose(filled_file) + composer = Composer(base_doc) + + # 添加其他需要合并的文档 + other_files = [docx_file_path] + for file in other_files: + if os.path.exists(file): + doc_to_append = Document_compose(file) + composer.append(doc_to_append) + + # 保存最终合并的文档 + save_path = f'{current_temp_path}/{project.name}-{doc_name}.docx' + composer.save(save_path) + return save_path + + +# 处理文档转换 +def handle_conver_file(doc_name, full_content, project, save_document, request, category, generation_request_id: str): + import logging + logger = logging.getLogger(__name__) + + try: + base_path = os.path.dirname(os.path.abspath(sys.argv[0])) + logger.info(f"base_path: {base_path}") + if not os.path.exists(f"{base_path}/workspace"): + os.makedirs(f"{base_path}/workspace") + import uuid + current_temp_path = f"{base_path}/workspace/{generation_request_id}_{uuid.uuid4().hex}" + os.makedirs(current_temp_path) + + yield DocumentSSEData( + eventType="convert_start", + message=f"开始转换文档: {doc_name}", + documentName=doc_name, + documentIndex=0 + ) + + processed_content = None + for event in process_drawio_images(full_content, current_temp_path): + if isinstance(event, str): + processed_content = event + else: + yield event + + if processed_content is None: + processed_content = full_content + processed_content = sanitize_generated_markdown(processed_content) + + yield DocumentSSEData( + eventType="convert_processing", + message=f"Markdown文件生成完成", + documentName=doc_name, + documentIndex=0 + ) + + md_file_path = f"{current_temp_path}/{doc_name}.md" + with open(md_file_path, mode='w', encoding="utf-8") as file: + file.write(processed_content) + + template_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates/template.docx") + # mermaid_filter_lua_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tool/mermaid-filter.lua") + # 使用库来进行文档转换 + + # 验证模板文件是否存在 + if not os.path.exists(template_file): + logger.error(f"模板文件不存在: {template_file}") + yield DocumentSSEData( + eventType="convert_error", + message=f"模板文件不存在: {template_file}", + documentName=doc_name, + documentIndex=0 + ) + return + + logger.info(f"开始Markdown转Word转换,文档名: {doc_name}, 模板路径: {template_file}") + logger.info(f"Markdown内容长度: {len(processed_content)} 字符") + + converter = MarkdownToDocx(template_file, font_name="宋体") + docx_file_path = f"{current_temp_path}/{doc_name}.docx" + + try: + converter.convert(processed_content, docx_file_path) + logger.info(f"Markdown转Word成功,输出文件: {docx_file_path}") + except Exception as convert_ex: + logger.error(f"Markdown转Word失败: {str(convert_ex)}", exc_info=True) + # 保存原始Markdown以便调试 + debug_md_path = f"{current_temp_path}/{doc_name}_debug.md" + with open(debug_md_path, 'w', encoding='utf-8') as debug_f: + debug_f.write(processed_content) + logger.info(f"已保存调试用的Markdown文件: {debug_md_path}") + raise convert_ex + # 转换图表的题注。 + from agents.ai_agents.document_agent.utils import docx_util + + try: + logger.info(f"开始修复表格边框: {docx_file_path}") + docx_util.fix_table_borders(docx_file_path) + logger.info(f"开始添加题注: {docx_file_path}") + docx_util.add_captions(docx_file_path) + logger.info(f"开始修复表格单元格样式: {docx_file_path}") + docx_util.fix_table_cell_styles(docx_file_path) + logger.info(f"文档后处理完成: {docx_file_path}") + except Exception as postprocess_ex: + logger.error(f"文档后处理失败: {str(postprocess_ex)}", exc_info=True) + raise postprocess_ex + + yield DocumentSSEData( + eventType="convert_processing", + message=f"Word文档生成完成", + documentName=doc_name, + documentIndex=0 + ) + + # pandoc_path = "pandoc" + # sys_platform = platform.system() + # + # if sys_platform == "Linux": + # pandoc_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tool/pandoc") + # elif sys_platform == "Windows": + # pandoc_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tool/pandoc.exe") + # # 执行命令行 + # try: + # command = (f'{pandoc_path} "{md_file_path}" -o ' + # f'"{current_temp_path}/{doc_name}.docx" --standalone ' + # f'--reference-doc "{template_file}"') + # logging.info(f"command: {command}") + # run_command(command, work=current_temp_path) + # except Exception as ex: + # logger.error(f"文档转换失败 ({doc_name}),返回码: {ex}") + # # 检查输出文件是否存在 + # docx_file_path = f"{current_temp_path}/{doc_name}.docx" + if not os.path.exists(docx_file_path): + yield DocumentSSEData( + eventType="convert_error", + message=f"Word文档生成失败", + documentName=doc_name, + documentIndex=0 + ) + return + # 基于模板再次生成详细的文档 + convert_docx_path = convert_template_docx(docx_file_path, project, doc_name, current_temp_path) + docx_util.postprocess_merged_document(convert_docx_path, project_name=project.name, doc_name=doc_name) + docx_util.enable_update_fields_on_open(convert_docx_path) + + yield DocumentSSEData( + eventType="convert_processing", + message=f"模板文档生成完成,开始上传", + documentName=doc_name, + documentIndex=0 + ) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + object_name = f'{doc_name}_{timestamp}.docx' + if request.version != '' and request.version is not None: + object_name = f'{doc_name}_{request.version}_{timestamp}.docx' + minio_url = minio_client.upload_file_from_path( + file_path=convert_docx_path, + object_name=object_name, + content_type="application/octet-stream" + ) + + # 确定文档分类 + # category = determine_document_category(doc_name) + + # 保存到数据库 + # show_name 包含时间戳(不包含UUID),格式如:运行方案说明_20260625_112409_671606.docx + timestamp_without_microseconds = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + file_name = f'{doc_name}_{timestamp_without_microseconds}.docx' + if request.version != '' and request.version is not None: + file_name = f'{doc_name}_{request.version}_{timestamp_without_microseconds}.docx' + generated_doc = FileLinkSchema( + object_name=object_name, + url=minio_url, + file_name=file_name, + type=category + ) + save_document.append(generated_doc) + logger.info(f"文档转换并上传成功: {doc_name}") + + yield DocumentSSEData( + eventType="convert_complete", + message=f"文档转换并上传成功: {doc_name}", + documentName=doc_name, + documentIndex=0 + ) + except Exception as ex: + logger.error(f"文档转换失败 ({doc_name}): {str(ex)}", exc_info=True) + yield DocumentSSEData( + eventType="convert_error", + message=f"文档转换失败: {str(ex)}", + documentName=doc_name, + documentIndex=0 + ) + return + + +def determine_document_category(doc_name: str) -> str: + """ + 根据文档名称确定分类 + + :param doc_name: 文档名称 + :return: 文档分类 + """ + for category, doc_types in DOCUMENT_TYPES.items(): + if doc_name in doc_types: + return DocumentCategoryEnum(category).name + + # 默认返回需求文档类 + return DocumentCategoryEnum.REQUIREMENT.name