app.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. """Info Extractor Web 后端.
  2. 提供 Flask API 服务供 Vue 前端调用:
  3. - 文档上传与文本提取(PDF / Word)
  4. - 关键信息提取(流式返回思考过程 + 标注结果)
  5. - 关键信息定义管理
  6. """
  7. from __future__ import annotations
  8. import json
  9. import logging
  10. import os
  11. import queue
  12. import re
  13. import sys
  14. import tempfile
  15. import threading
  16. import time
  17. import uuid
  18. from pathlib import Path
  19. from typing import Any, Iterator
  20. from flask import Flask, Response, jsonify, request, send_from_directory
  21. from flask_cors import CORS
  22. from dms import init_dms
  23. # 文档解析依赖
  24. try:
  25. import fitz # PyMuPDF
  26. except ImportError:
  27. fitz = None
  28. try:
  29. from docx import Document
  30. except ImportError:
  31. Document = None
  32. from src.extractor import InfoExtractor
  33. logging.basicConfig(
  34. level=logging.INFO,
  35. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  36. )
  37. logger = logging.getLogger(__name__)
  38. app = Flask(__name__)
  39. CORS(app)
  40. # DMS业务使用独立的 /api/v1 Blueprint。初始化仅注册DMS基础设施,
  41. # 不改变既有 /api/* AI接口的响应、认证或错误处理行为。
  42. init_dms(app)
  43. # ===== 前端静态托管(一体化分发用)=====
  44. def _resolve_frontend_dist() -> Path | None:
  45. """定位前端 dist 目录:优先环境变量,其次 PyInstaller 资源,最后开发路径"""
  46. env_path = os.environ.get("DMS_FRONTEND_DIST", "").strip()
  47. if env_path:
  48. p = Path(env_path)
  49. return p if p.exists() else None
  50. if hasattr(sys, "_MEIPASS"):
  51. p = Path(sys._MEIPASS) / "frontend_dist"
  52. return p if p.exists() else None
  53. dev = Path(__file__).resolve().parent.parent / "frontend" / "dist"
  54. return dev if dev.exists() else None
  55. FRONTEND_DIST = _resolve_frontend_dist()
  56. if FRONTEND_DIST is not None:
  57. @app.route("/", defaults={"path": ""})
  58. @app.route("/<path:path>")
  59. def serve_spa(path: str):
  60. # /api/* 已由上方蓝图和显式路由优先匹配;漏网的 api 请求返回 404
  61. if path.startswith("api/"):
  62. return jsonify({"error": "Not found"}), 404
  63. candidate = FRONTEND_DIST / path
  64. if path and candidate.is_file():
  65. return send_from_directory(FRONTEND_DIST, path)
  66. # 其余路径统一返回 index.html,交给前端路由
  67. return send_from_directory(FRONTEND_DIST, "index.html")
  68. logger.info("Frontend dist mounted at: %s", FRONTEND_DIST)
  69. else:
  70. logger.warning("Frontend dist not found; running in API-only mode")
  71. # ===== 前端静态托管 END =====
  72. # 从环境变量读取后端配置
  73. # 例如:INFO_EXTRACTOR_API_KEY=your-api-key INFO_EXTRACTOR_MODEL_NAME=glm-5.2 INFO_EXTRACTOR_BASE_URL=https://... python app.py
  74. API_KEY = os.environ.get("INFO_EXTRACTOR_API_KEY", "")
  75. MODEL_NAME = os.environ.get("INFO_EXTRACTOR_MODEL_NAME", "glm-5.2")
  76. BASE_URL = os.environ.get("INFO_EXTRACTOR_BASE_URL", "https://open.bigmodel.cn/api/coding/paas/v4")
  77. logger.info("Backend config: MODEL_NAME=%s, BASE_URL=%s, API_KEY_PREFIX=%s", MODEL_NAME, BASE_URL, API_KEY[:6] if API_KEY else "")
  78. # 上传文件临时存储目录
  79. UPLOAD_DIR = Path(tempfile.gettempdir()) / "info_extractor_uploads"
  80. UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
  81. # 关键信息定义列表持久化文件
  82. DATA_DIR = Path(__file__).parent / "data"
  83. DATA_DIR.mkdir(parents=True, exist_ok=True)
  84. DEFINITIONS_FILE = DATA_DIR / "definitions.json"
  85. DEFAULT_DEFINITION: dict[str, Any] = {
  86. "metadata": {
  87. "node_label": "PDJH",
  88. "node_name": "派对计划",
  89. "node_type": "concept",
  90. },
  91. "subnodes": {
  92. "JBXX": {
  93. "metadata": {
  94. "node_label": "JBXX",
  95. "node_name": "基本信息",
  96. "node_type": "concept",
  97. },
  98. "subnodes": {
  99. "PDMC": {
  100. "metadata": {
  101. "node_label": "PDMC",
  102. "node_name": "派对名称",
  103. "node_type": "property",
  104. "value_type": "string",
  105. "is_list": False,
  106. }
  107. },
  108. "PDKSSJ": {
  109. "metadata": {
  110. "node_label": "PDKSSJ",
  111. "node_name": "派对开始时间",
  112. "node_type": "property",
  113. "value_type": "date_time",
  114. "is_list": False,
  115. }
  116. },
  117. "PDZRS": {
  118. "metadata": {
  119. "node_label": "PDZRS",
  120. "node_name": "派对总人数",
  121. "node_type": "property",
  122. "value_type": "integer",
  123. "is_list": False,
  124. }
  125. },
  126. "PDZYS": {
  127. "metadata": {
  128. "node_label": "PDZYS",
  129. "node_name": "派对总预算",
  130. "node_type": "property",
  131. "value_type": "float",
  132. "is_list": False,
  133. }
  134. },
  135. "SFBA": {
  136. "metadata": {
  137. "node_label": "SFBA",
  138. "node_name": "是否备案",
  139. "node_type": "property",
  140. "value_type": "boolean",
  141. "is_list": False,
  142. }
  143. },
  144. },
  145. },
  146. "RYXX": {
  147. "metadata": {
  148. "node_label": "RYXX",
  149. "node_name": "人员信息",
  150. "node_type": "concept",
  151. },
  152. "subnodes": {
  153. "ZCH": {
  154. "metadata": {
  155. "node_label": "ZCH",
  156. "node_name": "总策划",
  157. "node_type": "property",
  158. "value_type": "class",
  159. "is_list": False,
  160. },
  161. "subnodes": {
  162. "XM": {
  163. "metadata": {
  164. "node_label": "XM",
  165. "node_name": "姓名",
  166. "node_type": "property",
  167. "value_type": "string",
  168. "is_list": False,
  169. }
  170. },
  171. "XB": {
  172. "metadata": {
  173. "node_label": "XB",
  174. "node_name": "性别",
  175. "node_type": "property",
  176. "value_type": "string",
  177. "is_list": False,
  178. }
  179. },
  180. },
  181. },
  182. "RYQD": {
  183. "metadata": {
  184. "node_label": "RYQD",
  185. "node_name": "人员清单",
  186. "node_type": "property",
  187. "value_type": "class",
  188. "is_list": True,
  189. },
  190. "subnodes": {
  191. "XM": {
  192. "metadata": {
  193. "node_label": "XM",
  194. "node_name": "姓名",
  195. "node_type": "property",
  196. "value_type": "string",
  197. "is_list": False,
  198. }
  199. },
  200. "XB": {
  201. "metadata": {
  202. "node_label": "XB",
  203. "node_name": "性别",
  204. "node_type": "property",
  205. "value_type": "string",
  206. "is_list": False,
  207. }
  208. },
  209. },
  210. },
  211. },
  212. },
  213. "HDXX": {
  214. "metadata": {
  215. "node_label": "HDXX",
  216. "node_name": "活动信息",
  217. "node_type": "concept",
  218. },
  219. "subnodes": {
  220. "PBYXQD": {
  221. "metadata": {
  222. "node_label": "PBYXQD",
  223. "node_name": "破冰游戏清单",
  224. "node_type": "property",
  225. "value_type": "string",
  226. "is_list": True,
  227. }
  228. }
  229. },
  230. },
  231. },
  232. }
  233. def _load_definitions() -> list[dict[str, Any]]:
  234. """从 JSON 文件加载关键信息定义列表."""
  235. if not DEFINITIONS_FILE.exists():
  236. return []
  237. try:
  238. with open(DEFINITIONS_FILE, "r", encoding="utf-8") as f:
  239. data = json.load(f)
  240. if isinstance(data, list):
  241. return data
  242. if isinstance(data, dict):
  243. # 兼容旧版单条定义
  244. return [_wrap_definition(data)]
  245. except Exception as exc:
  246. logger.warning("加载定义文件失败: %s", exc)
  247. return []
  248. def _save_definitions(definitions: list[dict[str, Any]]) -> None:
  249. """保存关键信息定义列表到 JSON 文件."""
  250. with open(DEFINITIONS_FILE, "w", encoding="utf-8") as f:
  251. json.dump(definitions, f, ensure_ascii=False, indent=2)
  252. def _wrap_definition(definition: dict[str, Any], name: str = "", description: str = "") -> dict[str, Any]:
  253. """将原始定义包装为列表项."""
  254. now = time.strftime("%Y-%m-%d %H:%M:%S")
  255. metadata = definition.get("metadata", {})
  256. if not name:
  257. name = metadata.get("node_name", "未命名定义")
  258. return {
  259. "id": str(uuid.uuid4()),
  260. "name": name,
  261. "description": description,
  262. "definition": definition,
  263. "created_at": now,
  264. "updated_at": now,
  265. }
  266. # 全局定义列表
  267. _definitions: list[dict[str, Any]] = _load_definitions()
  268. if not _definitions:
  269. _definitions = [_wrap_definition(DEFAULT_DEFINITION, name="派对计划", description="示例关键信息定义")]
  270. _save_definitions(_definitions)
  271. def _extract_text_from_pdf(file_path: str) -> str:
  272. """从 PDF 中提取文本."""
  273. if fitz is None:
  274. raise RuntimeError("PyMuPDF 未安装,无法解析 PDF")
  275. text_parts: list[str] = []
  276. with fitz.open(file_path) as doc:
  277. for page in doc:
  278. text_parts.append(page.get_text())
  279. return "\n\n".join(text_parts)
  280. def _extract_text_from_docx(file_path: str) -> str:
  281. """从 Word 文档中提取文本."""
  282. if Document is None:
  283. raise RuntimeError("python-docx 未安装,无法解析 Word 文档")
  284. doc = Document(file_path)
  285. text_parts: list[str] = []
  286. for para in doc.paragraphs:
  287. if para.text.strip():
  288. text_parts.append(para.text)
  289. return "\n\n".join(text_parts)
  290. def _to_markdown(text: str) -> str:
  291. """简单地将正文文本转为 markdown 格式.
  292. 当前只做基本分段和标题检测,未来可接入更强的文档解析模型。
  293. """
  294. lines = text.split("\n")
  295. result: list[str] = []
  296. for line in lines:
  297. stripped = line.strip()
  298. if not stripped:
  299. result.append("")
  300. continue
  301. # 简单启发式:短且没有标点的行可能是标题
  302. if len(stripped) < 30 and not re.search(r"[。,;:!?]", stripped):
  303. result.append(f"## {stripped}")
  304. else:
  305. result.append(stripped)
  306. return "\n\n".join(result)
  307. def _clean_markdown_for_extraction(markdown: str) -> str:
  308. """将 markdown 还原为纯文本供 LLM 提取."""
  309. text = re.sub(r"^#+\s*", "", markdown, flags=re.MULTILINE)
  310. return text.strip()
  311. @app.route("/api/health", methods=["GET"])
  312. def health():
  313. return jsonify({"status": "ok"})
  314. @app.route("/api/upload", methods=["POST"])
  315. def upload_file():
  316. """上传 PDF 或 Word 文档,返回文档 ID 和提取的文本内容."""
  317. if "file" not in request.files:
  318. return jsonify({"error": "未提供文件"}), 400
  319. file = request.files["file"]
  320. if file.filename == "":
  321. return jsonify({"error": "文件名为空"}), 400
  322. ext = Path(file.filename).suffix.lower()
  323. if ext not in (".pdf", ".docx", ".doc"):
  324. return jsonify({"error": "仅支持 PDF 和 Word 文档"}), 400
  325. doc_id = str(uuid.uuid4())
  326. file_path = UPLOAD_DIR / f"{doc_id}{ext}"
  327. file.save(str(file_path))
  328. try:
  329. if ext == ".pdf":
  330. raw_text = _extract_text_from_pdf(str(file_path))
  331. else:
  332. raw_text = _extract_text_from_docx(str(file_path))
  333. except Exception as exc:
  334. logger.exception("文档解析失败")
  335. return jsonify({"error": f"文档解析失败: {exc}"}), 500
  336. markdown = _to_markdown(raw_text)
  337. return jsonify({
  338. "doc_id": doc_id,
  339. "filename": file.filename,
  340. "markdown": markdown,
  341. "raw_text": raw_text,
  342. })
  343. @app.route("/api/definition", methods=["GET", "POST"])
  344. def definition():
  345. """获取或保存关键信息定义列表."""
  346. global _definitions
  347. if request.method == "GET":
  348. return jsonify({"definitions": _definitions})
  349. data = request.get_json()
  350. if not isinstance(data, dict) or "definitions" not in data:
  351. return jsonify({"error": "非法的关键信息定义列表格式"}), 400
  352. definitions = data["definitions"]
  353. if not isinstance(definitions, list):
  354. return jsonify({"error": "definitions 必须是数组"}), 400
  355. now = time.strftime("%Y-%m-%d %H:%M:%S")
  356. for item in definitions:
  357. if not isinstance(item, dict) or "definition" not in item:
  358. return jsonify({"error": "列表项必须包含 definition 字段"}), 400
  359. item.setdefault("id", str(uuid.uuid4()))
  360. item.setdefault("name", "未命名定义")
  361. item.setdefault("description", "")
  362. item["updated_at"] = now
  363. _definitions = definitions
  364. _save_definitions(_definitions)
  365. return jsonify({"success": True})
  366. @app.route("/api/definition/import", methods=["POST"])
  367. def import_definition():
  368. """导入 JSON 格式定义,自动提取名称和描述."""
  369. data = request.get_json()
  370. if not isinstance(data, dict):
  371. return jsonify({"error": "请求体必须是 JSON 对象"}), 400
  372. definition = data.get("definition")
  373. if not isinstance(definition, dict) or "metadata" not in definition:
  374. return jsonify({"error": "非法的关键信息定义格式"}), 400
  375. name = data.get("name", "").strip()
  376. description = data.get("description", "").strip()
  377. item = _wrap_definition(definition, name=name, description=description)
  378. _definitions.append(item)
  379. _save_definitions(_definitions)
  380. return jsonify({"success": True, "definition": item})
  381. @app.route("/api/extract", methods=["POST"])
  382. def extract():
  383. """流式提取关键信息.
  384. 前端通过 SSE 接收:
  385. - type=thinking: 模型思考过程/流式输出片段
  386. - type=annotated: 最终标注后的文本
  387. - type=done: 完成
  388. - type=error: 错误
  389. """
  390. data = request.get_json()
  391. article = data.get("article", "")
  392. mode = data.get("mode", "two_phase")
  393. model_name = data.get("model_name", MODEL_NAME)
  394. base_url = data.get("base_url", BASE_URL)
  395. logger.info("Extract request: model_name=%s, base_url=%s", model_name, base_url)
  396. if not article:
  397. return jsonify({"error": "文章内容不能为空"}), 400
  398. definition_data = data.get("definition")
  399. definition_id = data.get("definition_id")
  400. if definition_data is None:
  401. if definition_id:
  402. for item in _definitions:
  403. if item.get("id") == definition_id:
  404. definition_data = item.get("definition")
  405. break
  406. if definition_data is None:
  407. return jsonify({"error": "未找到指定的关键信息定义"}), 400
  408. else:
  409. if not _definitions:
  410. return jsonify({"error": "未配置关键信息定义"}), 400
  411. definition_data = _definitions[0].get("definition")
  412. def generate() -> Iterator[str]:
  413. if not API_KEY:
  414. yield f"data: {json.dumps({'type': 'error', 'message': '后端未配置 API Key'}, ensure_ascii=False)}\n\n"
  415. return
  416. thinking_queue: queue.Queue[Any] = queue.Queue()
  417. extract_result: dict[str, Any] = {}
  418. extract_error: list[Exception] = []
  419. def run_extraction() -> None:
  420. try:
  421. extractor = InfoExtractor(
  422. model_name=model_name,
  423. api_key=API_KEY,
  424. base_url=base_url or None,
  425. temperature=0,
  426. max_retries=1,
  427. )
  428. extractor.stream_callback = thinking_queue.put
  429. def on_step(label: str) -> None:
  430. thinking_queue.put(("__step__", label))
  431. extractor.step_callback = on_step
  432. result = extractor.extract(
  433. article=article,
  434. definition=definition_data,
  435. mode=mode,
  436. verbose=True,
  437. annotate=True,
  438. )
  439. extract_result["data"] = result
  440. except Exception as exc:
  441. logger.exception("提取失败")
  442. extract_error.append(exc)
  443. extraction_thread = threading.Thread(target=run_extraction)
  444. extraction_thread.start()
  445. # 实时从队列读取思考过程并推送
  446. while extraction_thread.is_alive() or not thinking_queue.empty():
  447. try:
  448. chunk = thinking_queue.get(timeout=0.1)
  449. except queue.Empty:
  450. continue
  451. if isinstance(chunk, tuple) and chunk and chunk[0] == "__step__":
  452. yield f"data: {json.dumps({'type': 'step', 'content': chunk[1]}, ensure_ascii=False)}\n\n"
  453. else:
  454. yield f"data: {json.dumps({'type': 'thinking', 'content': chunk}, ensure_ascii=False)}\n\n"
  455. extraction_thread.join()
  456. if extract_error:
  457. yield f"data: {json.dumps({'type': 'error', 'message': str(extract_error[0])}, ensure_ascii=False)}\n\n"
  458. return
  459. result = extract_result.get("data", {})
  460. annotated_text = result.get("annotated_text", "") if isinstance(result, dict) else ""
  461. yield f"data: {json.dumps({'type': 'annotated', 'content': annotated_text}, ensure_ascii=False)}\n\n"
  462. yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
  463. response = Response(generate(), mimetype="text/event-stream")
  464. response.headers["Access-Control-Allow-Origin"] = "*"
  465. return response
  466. # 标注结果持久化目录
  467. ANNOTATION_DIR = DATA_DIR / "annotations"
  468. ANNOTATION_DIR.mkdir(parents=True, exist_ok=True)
  469. def _annotation_path(doc_key: str) -> Path:
  470. """根据前端传入的 doc_key 生成本地存储路径.
  471. doc_key 由前端生成(如 doc_id + definition_id),仅允许字母数字与下划线连字符,
  472. 避免路径穿越风险。
  473. """
  474. safe = re.sub(r"[^A-Za-z0-9_\-.]", "_", doc_key)
  475. if not safe:
  476. safe = "default"
  477. return ANNOTATION_DIR / f"{safe}.json"
  478. @app.route("/api/annotation", methods=["GET", "POST"])
  479. def annotation():
  480. """保存或读取标注后的文章内容.
  481. GET ?doc_key=xxx -> {"content": "..."}
  482. POST {doc_key, content} -> {"success": true}
  483. """
  484. if request.method == "GET":
  485. doc_key = request.args.get("doc_key", "")
  486. path = _annotation_path(doc_key)
  487. if not path.exists():
  488. return jsonify({"content": ""})
  489. try:
  490. with open(path, "r", encoding="utf-8") as f:
  491. data = json.load(f)
  492. return jsonify({"content": data.get("content", "")})
  493. except Exception as exc:
  494. logger.warning("读取标注失败: %s", exc)
  495. return jsonify({"content": ""})
  496. data = request.get_json() or {}
  497. doc_key = data.get("doc_key", "")
  498. content = data.get("content", "")
  499. if not doc_key:
  500. return jsonify({"error": "缺少 doc_key"}), 400
  501. path = _annotation_path(doc_key)
  502. try:
  503. with open(path, "w", encoding="utf-8") as f:
  504. json.dump({"content": content, "updated_at": time.strftime("%Y-%m-%d %H:%M:%S")}, f, ensure_ascii=False, indent=2)
  505. except Exception as exc:
  506. logger.exception("保存标注失败")
  507. return jsonify({"error": f"保存失败: {exc}"}), 500
  508. return jsonify({"success": True})
  509. if __name__ == "__main__":
  510. app.run(
  511. host=os.environ.get("DMS_HOST", "0.0.0.0"),
  512. port=int(os.environ.get("DMS_PORT", "9345")),
  513. debug=False,
  514. )