demo/document.py

1150 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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