report_utils.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. # -*- coding: utf-8 -*-
  2. """
  3. 审查报告生成工具模块
  4. 功能:
  5. - 将 Markdown 格式的审查报告转换为 Word (.docx) 文档
  6. - 使用 pypandoc + 自定义样式模板(data/template.docx)
  7. - 生成静态文件下载 URL
  8. 工具列表:
  9. - convert_markdown_to_docx: Markdown → Word 文档转换
  10. - get_docx_download_url: 生成 Word 文档的下载 URL
  11. """
  12. import os
  13. import re
  14. import uuid
  15. from pathlib import Path
  16. from urllib.parse import quote
  17. # ============================================================
  18. # 配置常量
  19. # ============================================================
  20. # 项目根目录
  21. PROJECT_ROOT = Path(__file__).parent.parent
  22. # 样式模板路径
  23. TEMPLATE_FILENAME = "template.docx"
  24. TEMPLATE_PATH = str(PROJECT_ROOT / "data" / TEMPLATE_FILENAME)
  25. # 报告输出目录(StaticFiles 挂载的静态目录)
  26. REPORT_OUTPUT_DIR = PROJECT_ROOT / "static" / "reports"
  27. # 服务器地址(读取 .env,无配置则用 localhost)
  28. def _get_server_host() -> str:
  29. """从 .env 读取 SERVER_HOST,默认 localhost:8000。"""
  30. from dotenv import dotenv_values
  31. cfg = dotenv_values(str(PROJECT_ROOT / ".env"))
  32. host = cfg.get("HOST", "0.0.0.0")
  33. port = cfg.get("PORT", "8000")
  34. # 对外暴露用 localhost 或配置的域名
  35. return cfg.get("SERVER_HOST", f"http://{host}:{port}")
  36. SERVER_HOST = _get_server_host()
  37. # ============================================================
  38. # Markdown → Word 转换
  39. # ============================================================
  40. def _normalize_markdown(md_content: str) -> str:
  41. """规范化 Markdown 内容,修复常见的 LLM 输出格式问题。
  42. 修复以下问题:
  43. 1. 标题行首有空格 → 去除(保证 ## 顶格)
  44. 2. `##` 与标题文字之间缺少空格 → 补空格
  45. 3. 标题前缺少空行 → 补空行
  46. 4. 以 `**##` 或 `**###` 开头的伪标题 → 转为正常标题
  47. 5. 全角 `#` 替换为半角 `#`
  48. 注意:不修改表格内部内容,避免破坏正确的 markdown 表格格式。
  49. Args:
  50. md_content: 原始 Markdown 内容
  51. Returns:
  52. str: 规范化后的 Markdown 内容
  53. """
  54. import re
  55. lines = md_content.split("\n")
  56. result = []
  57. in_table = False # 跟踪是否在表格内
  58. for i, line in enumerate(lines):
  59. # 检测表格边界
  60. stripped_line = line.strip()
  61. if stripped_line.startswith("|") and stripped_line.endswith("|"):
  62. in_table = True
  63. elif stripped_line.startswith("|") and "---" in stripped_line:
  64. in_table = True # 表格分隔行
  65. elif in_table and not stripped_line.startswith("|"):
  66. in_table = False
  67. # 1. 全角 # 替换为半角
  68. line = line.replace("\uff03", "#")
  69. # 表格内的行:跳过标题规范化处理,原样保留
  70. if in_table:
  71. result.append(line)
  72. continue
  73. # 2. 标题行首去空格 + 补空格:检测 ## 开头的行
  74. stripped = line.lstrip()
  75. # 匹配: 行首(可能有空格)的 # 标记,后面可选空格 + 文字
  76. if re.match(r"^#{1,6}(\s|$)", stripped) or re.match(r"^#{1,6}[^\s#]", stripped):
  77. # 确保 # 后面有空格(如 "##标题" → "## 标题")
  78. fixed = re.sub(r"^(#{1,6})\s*", r"\1 ", stripped)
  79. fixed = fixed.rstrip()
  80. # 确保标题前有空行(避免标题粘在上文后)
  81. if result and result[-1].strip() != "":
  82. result.append("")
  83. result.append(fixed)
  84. continue
  85. # 3. 检测 **## 标题** 这类伪标题 → 转为 ## 标题
  86. bold_header = re.match(r"^\*\*(#{1,6})\s+(.+?)\*\*\s*$", line)
  87. if bold_header:
  88. fixed = f"{bold_header.group(1)} {bold_header.group(2).rstrip('*').strip()}"
  89. if result and result[-1].strip() != "":
  90. result.append("")
  91. result.append(fixed)
  92. continue
  93. # 4. 检测 **### 标题** 变体
  94. bold_header2 = re.match(r"^\*\*(#{1,6})\s*(.+?)\*\*\s*$", line)
  95. if bold_header2:
  96. fixed = f"{bold_header2.group(1)} {bold_header2.group(2).rstrip('*').strip()}"
  97. if result and result[-1].strip() != "":
  98. result.append("")
  99. result.append(fixed)
  100. continue
  101. result.append(line)
  102. return "\n".join(result)
  103. def convert_markdown_to_docx(md_content: str, save_path: str) -> None:
  104. """将 Markdown 内容转换为 Word (.docx) 文档。
  105. 使用 pypandoc 进行转换,应用 data/template.docx 作为样式模板。
  106. 首次使用时会自动下载 pandoc。
  107. Args:
  108. md_content: Markdown 格式的报告内容
  109. save_path: 输出 .docx 文件的完整路径
  110. Raises:
  111. FileNotFoundError: 样式模板不存在
  112. RuntimeError: pandoc 转换失败
  113. """
  114. try:
  115. import pypandoc
  116. except ImportError:
  117. raise ImportError(
  118. "pypandoc 未安装。请执行: pip install pypandoc"
  119. )
  120. # 自动检测并下载 pandoc
  121. try:
  122. pypandoc.get_pandoc_path()
  123. except OSError:
  124. print("[报告] 未检测到 pandoc,正在自动下载...")
  125. pypandoc.download_pandoc()
  126. # 清理内容
  127. report_content = md_content.strip()
  128. if not report_content:
  129. raise ValueError("报告内容为空,无法生成文档")
  130. # 规范化 Markdown 格式(修复 LLM 输出中常见的排版问题)
  131. # report_content = _normalize_markdown(report_content)
  132. print("[报告] Markdown 规范化完成")
  133. # 检查模板
  134. if not os.path.exists(TEMPLATE_PATH):
  135. raise FileNotFoundError(
  136. f"样式模板不存在!路径:{TEMPLATE_PATH}\n"
  137. f"请将模板文件放置到 data/template.docx"
  138. )
  139. # 确保输出目录存在
  140. os.makedirs(os.path.dirname(save_path), exist_ok=True)
  141. # pandoc 转换参数
  142. extra_args = [
  143. "--standalone",
  144. f"--reference-doc={TEMPLATE_PATH}",
  145. "--from=gfm", # GitHub Flavored Markdown,表格支持更好
  146. "--wrap=none", # 禁止自动换行,保持表格单元格完整
  147. "--columns=120", # 宽列宽,避免窄列强制断行
  148. ]
  149. try:
  150. pypandoc.convert_text(
  151. source=report_content,
  152. format="md",
  153. to="docx",
  154. outputfile=save_path,
  155. extra_args=extra_args,
  156. encoding="utf-8",
  157. )
  158. print(f"[报告] ✅ 转换成功!文件路径:{save_path}")
  159. except Exception as e:
  160. raise RuntimeError(f"pandoc 转换失败: {e}")
  161. # ============================================================
  162. # 下载 URL 生成
  163. # ============================================================
  164. def get_docx_download_url(word_filename: str) -> str:
  165. """生成 Word 文档的下载 URL。
  166. Args:
  167. word_filename: Word 文档文件名(不含路径),如 "report_abc123.docx"
  168. Returns:
  169. str: 完整下载 URL,如 "http://localhost:8000/static/reports/report_abc123.docx"
  170. """
  171. return f"{SERVER_HOST}/static/reports/{quote(word_filename)}"
  172. def generate_report_filename(title: str = None) -> str:
  173. """生成审查报告文件名。
  174. Args:
  175. title: 报告标题(如煤矿名称),为空时使用默认名称
  176. Returns:
  177. str: 文件名,如 "XX煤矿配风计划审查报告_20260713_150132.docx"
  178. """
  179. from datetime import datetime
  180. date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
  181. if title:
  182. # 清理标题中的非法文件名字符
  183. safe_title = re.sub(r'[\\/:*?"<>|]', '', title).strip()
  184. if safe_title:
  185. return f"{safe_title}配风计划审查报告_{date_str}.docx"
  186. return f"配风计划审查报告_{date_str}.docx"
  187. def extract_mine_name(text: str) -> str | None:
  188. """从 PDF 文本中提取煤矿/公司名称。
  189. 在文本前 5000 字中查找含"煤矿/煤业/矿业/能源/公司"的行,
  190. 提取其中最可能的企业名称。
  191. Args:
  192. text: PDF 提取的文本内容
  193. Returns:
  194. str | None: 提取到的名称,未找到返回 None
  195. """
  196. import re
  197. head = text[:5000] if len(text) > 5000 else text
  198. # 先找含关键字的行(每行独立匹配,避免跨行粘连)
  199. lines = head.split('\n')
  200. candidates = []
  201. for line in lines:
  202. line = line.strip()
  203. if not line or len(line) < 4:
  204. continue
  205. # 匹配模式:任意前缀 + 煤矿/煤业/矿业 + 可选后缀
  206. m = re.search(
  207. r'([\u4e00-\u9fa5\w()()\u3000]+?'
  208. r'(?:煤矿|煤业|矿业|能源)'
  209. r'(?:有限责任公司|股份有限公司|集团有限公司|有限公司|集团公司|集团|公司)?)',
  210. line
  211. )
  212. if m:
  213. name = m.group(1).strip()
  214. if len(name) >= 4:
  215. candidates.append(name)
  216. # 匹配 XX矿(非矿山/矿区/矿井/矿务局/矿长)
  217. m2 = re.search(
  218. r'([\u4e00-\u9fa5\w()()\u3000]{4,20}矿)'
  219. r'(?!山|区|井|业|务|长|灯|车|石|泥|泉水)',
  220. line
  221. )
  222. if m2:
  223. name = m2.group(1).strip()
  224. if len(name) >= 4 and name not in candidates:
  225. candidates.append(name)
  226. if not candidates:
  227. return None
  228. # 返回最长匹配(通常最完整)
  229. candidates.sort(key=len, reverse=True)
  230. best = candidates[0]
  231. # 清理末尾多余字符
  232. best = re.sub(r'[,。;:、!?\s]+$', '', best)
  233. # 如果以"公司"结尾且前面有"有限/责任/集团",确保完整性
  234. # 如果名称太长(>30字),截取到最后一个关键字处
  235. if len(best) > 40:
  236. # 尝试截断到 煤矿/煤业/矿业/公司 处
  237. trunc = re.match(r'(.{1,30}(?:煤矿|煤业|矿业|集团|公司))', best)
  238. if trunc:
  239. best = trunc.group(1)
  240. return best if len(best) >= 4 else None
  241. def save_review_report(md_content: str, title: str = None) -> tuple[str, str]:
  242. """保存审查报告为 Word 文档,返回 (文件路径, 下载URL)。
  243. 一站式函数:生成文件名 → 转换 docx → 返回路径和下载链接。
  244. Args:
  245. md_content: Markdown 格式的审查报告
  246. title: 报告标题(煤矿名称),用于生成文件名
  247. Returns:
  248. tuple[str, str]: (本地文件路径, 下载URL)
  249. """
  250. filename = generate_report_filename(title)
  251. save_path = str(REPORT_OUTPUT_DIR / filename)
  252. convert_markdown_to_docx(md_content, save_path)
  253. download_url = get_docx_download_url(save_path)
  254. return save_path, download_url
  255. # ═══════════════════════════════════════════════════════════════
  256. # Agent 工具:保存 Markdown 报告
  257. # ═══════════════════════════════════════════════════════════════
  258. async def save_report(content: str, filename: str = "") -> str:
  259. """将 Markdown 格式的分析报告保存为 .md 文件,并返回下载链接。
  260. 当你需要生成通风分析报告、风量计算报告、数据解读报告等输出文档时调用此工具。
  261. 直接传入完整的 Markdown 内容即可,工具会自动生成文件名并保存到报告目录。
  262. 注意:你只需要调用一次本工具,将完整报告内容传入。不要尝试用 write_file
  263. 或 edit_file 等文件系统工具来写文件,那些操作已被权限限制。
  264. Args:
  265. content: 完整的 Markdown 格式报告内容
  266. filename: 可选,文件名(不含 .md 扩展名)。
  267. 不传则自动生成:report_YYYYMMDD_HHMMSS.md
  268. Returns:
  269. JSON 格式,包含 success、file_path(本地路径)、filename、
  270. download_url(可通过浏览器访问的下载链接)。
  271. """
  272. import json
  273. from datetime import datetime, timezone, timedelta
  274. if not content or not content.strip():
  275. return json.dumps({"error": "报告内容不能为空"}, ensure_ascii=False)
  276. os.makedirs(str(REPORT_OUTPUT_DIR), exist_ok=True)
  277. # 生成文件名
  278. if filename and filename.strip():
  279. safe_name = filename.strip().replace("/", "_").replace("\\", "_")
  280. safe_name = re.sub(r"[<>:\"|?*]", "_", safe_name)
  281. md_filename = f"{safe_name}.md"
  282. else:
  283. now = datetime.now(timezone(timedelta(hours=8)))
  284. md_filename = now.strftime("report_%Y%m%d_%H%M%S.md")
  285. file_path = str(REPORT_OUTPUT_DIR / md_filename)
  286. try:
  287. with open(file_path, "w", encoding="utf-8") as f:
  288. f.write(content.strip())
  289. # 构建下载 URL(文件名需 URL 编码,处理中文/特殊字符)
  290. download_url = f"{SERVER_HOST}/static/reports/{quote(md_filename)}"
  291. return json.dumps({
  292. "success": True,
  293. "file_path": file_path,
  294. "filename": md_filename,
  295. "download_url": download_url,
  296. "message": f"报告已保存为 {md_filename},可通过 {download_url} 下载",
  297. }, ensure_ascii=False)
  298. except Exception as e:
  299. return json.dumps({
  300. "success": False,
  301. "error": f"保存报告失败: {str(e)}",
  302. }, ensure_ascii=False)