app.py 19 KB

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