qualification.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import asyncio
  2. import os
  3. import uuid
  4. from datetime import datetime, timezone
  5. from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
  6. from sqlalchemy import text
  7. from backend.agents.qualification.graph import build_qualification_graph
  8. from backend.core.logger import get_logger
  9. from backend.dependencies import AsyncSessionLocal, get_current_user
  10. router = APIRouter()
  11. logger = get_logger(__name__)
  12. # ── 线程本地图:每个线程一份独立的图实例,避免并发竞争 ──
  13. import threading
  14. _graph_local = threading.local()
  15. def _get_graph():
  16. """获取线程本地的图实例(当前线程没有就编译一个)。"""
  17. if not hasattr(_graph_local, "graph"):
  18. _graph_local.graph = build_qualification_graph()
  19. return _graph_local.graph
  20. # ── GC 保护:模块级集合持有后台任务的强引用,防止被垃圾回收 ──
  21. _background_tasks: set[asyncio.Task] = set()
  22. QUALIFICATION_REVIEW_TIMEOUT_SECONDS = 15 * 60 # 审查超时阈值:15 分钟
  23. async def _mark_review_failed(review_id: str, error_msg: str) -> None:
  24. """把仍处于 processing 的记录标记为 failed(幂等:仅当当前是 processing 才改)。"""
  25. async with AsyncSessionLocal() as session:
  26. await session.execute(
  27. text("""
  28. UPDATE qualification_reviews
  29. SET status = 'failed', error_msg = :error_msg, updated_at = NOW()
  30. WHERE id = :review_id AND status = 'processing'
  31. """),
  32. {"review_id": review_id, "error_msg": error_msg[:1000]},
  33. )
  34. await session.commit()
  35. @router.post("/upload", status_code=202) # 202 Accepted:已接受、正在处理
  36. async def upload_qualification(
  37. file: UploadFile = File(...), # 上传的文件
  38. current_user: dict = Depends(get_current_user), # 鉴权:拿到当前用户
  39. ):
  40. """上传 PDF 资质材料,触发异步审查,立即返回 review_id。"""
  41. if not file.filename.lower().endswith(".pdf"): # 只收 PDF
  42. raise HTTPException(status_code=400, detail="仅支持 PDF 格式")
  43. review_id = str(uuid.uuid4())
  44. bidder_id = current_user["user_id"]
  45. tenant_id = current_user["tenant_id"]
  46. # 1. 读取并校验文件(用 await 读,避免阻塞事件循环)
  47. MAX_PDF_SIZE = 20 * 1024 * 1024 # 20MB 上限
  48. file_bytes = await file.read()
  49. if len(file_bytes) > MAX_PDF_SIZE:
  50. raise HTTPException(status_code=413, detail="文件过大,最大支持 20MB")
  51. if not file_bytes:
  52. raise HTTPException(status_code=400, detail="上传文件为空")
  53. # 暂存到临时目录
  54. import tempfile
  55. tmp_path = os.path.join(tempfile.gettempdir(), f"{review_id}_upload.pdf")
  56. with open(tmp_path, "wb") as f:
  57. f.write(file_bytes)
  58. logger.info("upload_qualification.file_saved", review_id=review_id, tmp_path=tmp_path)
  59. # 2. 写入 qualification_reviews 初始记录(status=processing)
  60. async with AsyncSessionLocal() as session:
  61. await session.execute(
  62. text("""
  63. INSERT INTO qualification_reviews (id, tenant_id, bidder_id, pdf_minio_path, status)
  64. VALUES (:id, :tenant_id, :bidder_id, :pdf_minio_path, 'processing')
  65. """),
  66. {"id": review_id, "tenant_id": tenant_id, "bidder_id": bidder_id,
  67. "pdf_minio_path": f"qualifications/{bidder_id}/{review_id}.pdf"},
  68. )
  69. await session.commit()
  70. # 3. 准备初始 State,后台启动图执行
  71. initial_state = {
  72. "messages": [], "bidder_id": bidder_id, "tenant_id": tenant_id,
  73. "review_id": review_id, "pdf_minio_path": "", "pdf_local_path": tmp_path,
  74. "raw_text": "", "page_count": 0, "structured": None,
  75. "dimension_scores": [], "weighted_score": 0.0, "issues": [],
  76. "summary": None, "fallback_used": False, "structured_output": None,
  77. }
  78. def _on_task_done(t: asyncio.Task):
  79. """任务结束回调:移除引用、清理临时文件、失败则标记 failed。"""
  80. _background_tasks.discard(t) # 从集合移除(释放强引用)
  81. if os.path.exists(tmp_path): # 清理临时 PDF
  82. try:
  83. os.remove(tmp_path)
  84. except OSError:
  85. pass
  86. mark_failed_msg = None
  87. if t.cancelled(): # 任务被取消(如服务重启)
  88. mark_failed_msg = "审查任务被服务重启中断,请重试。"
  89. logger.warning("qualification.background_task_cancelled", review_id=review_id)
  90. else:
  91. exc = t.exception() # 任务抛了异常
  92. if exc:
  93. mark_failed_msg = f"审查任务执行失败:{exc}"
  94. logger.error("qualification.background_task_failed", review_id=review_id,
  95. error=str(exc), exc_info=exc)
  96. if mark_failed_msg: # 异步把记录标记为 failed
  97. try:
  98. loop = asyncio.get_running_loop()
  99. loop.create_task(_mark_review_failed(review_id, mark_failed_msg))
  100. except RuntimeError:
  101. pass # 循环已关闭:下次查询走超时兜底
  102. graph = _get_graph() # 拿线程本地图
  103. task = asyncio.create_task(graph.ainvoke(initial_state)) # 后台执行
  104. _background_tasks.add(task) # GC 保护:持有强引用
  105. task.add_done_callback(_on_task_done) # 注册完成回调
  106. logger.info("upload_qualification.task_started", review_id=review_id)
  107. return { # 立即返回(不等审查完成)
  108. "review_id": review_id, "status": "processing",
  109. "message": "资质材料已上传,正在审查中,预计 30-60 秒完成。",
  110. }
  111. @router.get("/reviews/{review_id}")
  112. async def get_review(review_id: str, current_user: dict = Depends(get_current_user)):
  113. """查询审查状态/结果。processing / done / failed / 404。"""
  114. async with AsyncSessionLocal() as session:
  115. result = await session.execute(
  116. text("""
  117. SELECT id, status, scores, issues, summary, error_msg, created_at, updated_at
  118. FROM qualification_reviews
  119. WHERE id = :review_id AND bidder_id = :bidder_id
  120. """),
  121. {"review_id": review_id, "bidder_id": current_user["user_id"]},
  122. )
  123. row = result.mappings().fetchone()
  124. if not row: # 不存在或不属于自己
  125. raise HTTPException(status_code=404, detail="审查记录不存在")
  126. if row["status"] == "processing":
  127. # 超时兜底:防止后台任务中断后长期卡 processing
  128. last_ts = row["updated_at"] or row["created_at"]
  129. if isinstance(last_ts, datetime):
  130. elapsed = (datetime.now(timezone.utc) - last_ts).total_seconds()
  131. if elapsed >= QUALIFICATION_REVIEW_TIMEOUT_SECONDS:
  132. timeout_msg = "审查任务超时或被中断,请重新上传后重试。"
  133. await _mark_review_failed(review_id, timeout_msg)
  134. return {"review_id": review_id, "status": "failed", "error_msg": timeout_msg}
  135. return {"review_id": review_id, "status": "processing"}
  136. if row["status"] == "failed":
  137. return {"review_id": review_id, "status": "failed",
  138. "error_msg": row.get("error_msg") or "审查任务失败,请重新上传。"}
  139. # status == done:JSONB 列已被 asyncpg 自动反序列化为 dict/list,无需 json.loads
  140. def _to_dict(v):
  141. if v is None:
  142. return {}
  143. if isinstance(v, (dict, list)):
  144. return v
  145. import json as _json
  146. return _json.loads(v)
  147. scores_data = _to_dict(row["scores"])
  148. return {
  149. "review_id": review_id, "status": "done",
  150. "weighted_score": scores_data.get("weighted_score", 0),
  151. "dimension_scores": scores_data.get("dimension_scores", []),
  152. "issues": _to_dict(row["issues"]) if not isinstance(row["issues"], list) else row["issues"],
  153. "summary": _to_dict(row["summary"]),
  154. }
  155. @router.delete("/reviews/{review_id}", status_code=204)
  156. async def delete_review(review_id: str, current_user: dict = Depends(get_current_user)):
  157. """删除审查记录(WHERE 带 bidder_id,只能删自己的)。"""
  158. async with AsyncSessionLocal() as session:
  159. result = await session.execute(
  160. text("""
  161. DELETE FROM qualification_reviews
  162. WHERE id = :review_id AND bidder_id = :bidder_id
  163. """),
  164. {"review_id": review_id, "bidder_id": current_user["user_id"]},
  165. )
  166. await session.commit()
  167. if result.rowcount == 0: # 没删到任何行 = 不存在/无权限
  168. raise HTTPException(status_code=404, detail="记录不存在")
  169. @router.get("/reviews")
  170. async def list_reviews(current_user: dict = Depends(get_current_user)):
  171. """列出本人历史审查记录(摘要,按时间倒序)。"""
  172. async with AsyncSessionLocal() as session:
  173. result = await session.execute(
  174. text("""
  175. SELECT id, status, created_at,
  176. (scores::jsonb ->> 'weighted_score')::float AS weighted_score
  177. FROM qualification_reviews
  178. WHERE bidder_id = :bidder_id
  179. ORDER BY created_at DESC
  180. LIMIT 50
  181. """),
  182. {"bidder_id": current_user["user_id"]},
  183. )
  184. rows = result.mappings().all()
  185. items = [
  186. {"review_id": row["id"], "status": row["status"],
  187. "created_at": row["created_at"].isoformat() if row["created_at"] else None,
  188. "weighted_score": row["weighted_score"]}
  189. for row in rows
  190. ]
  191. return {"items": items, "total": len(items)}