middleware.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. """文档传输兼容层;原件解析与渲染仍由 Agent 的沙箱代码完成。"""
  2. from pathlib import PurePosixPath
  3. from threading import RLock
  4. from langchain.agents.middleware.types import AgentMiddleware
  5. from langchain_core.messages import ToolMessage
  6. _page_lock = RLock()
  7. def record_page_read(request, response):
  8. """记录实际文件工具读取及当时指纹;不接受模型自报已读页数。"""
  9. from extensions.main_fan.batch_context import current_scope
  10. scope = current_scope.get()
  11. call = request.tool_call
  12. if scope is None or not scope.active_workflow_id or call['name'] != 'read_file':
  13. return
  14. virtual = call.get('args', {}).get('file_path', '')
  15. if not isinstance(virtual, str) or not virtual.startswith('/workspace/'):
  16. return
  17. extension = PurePosixPath(virtual).suffix.lower()
  18. if extension not in {'.png', '.jpg', '.jpeg', '.webp', '.txt'}:
  19. return
  20. update = getattr(response, 'update', None)
  21. messages = [response] if isinstance(response, ToolMessage) else update.get('messages', []) if isinstance(update, dict) else []
  22. if not messages or any(getattr(message, 'status', 'error') == 'error' for message in messages):
  23. return
  24. from runtime.python_sandbox import digest, workspace_file
  25. from db import main_fan_store
  26. path = workspace_file(scope.user, scope.session, virtual)
  27. if not path.is_file():
  28. return
  29. offset = call.get('args', {}).get('offset') or 0
  30. limit = call.get('args', {}).get('limit') or 2000
  31. complete = extension != '.txt' or (offset == 0 and len(path.read_text().splitlines()) <= limit)
  32. with _page_lock:
  33. workflow = main_fan_store.workflow(scope.active_workflow_id, scope.user, scope.session)
  34. workflow['context'].setdefault('page_reads', {})[virtual] = {'sha256': digest(path), 'complete': complete}
  35. main_fan_store.update_workflow(scope.active_workflow_id, context=workflow['context'])
  36. def _pdf_read_feedback(request):
  37. call = request.tool_call
  38. from extensions.main_fan.batch_context import current_scope
  39. scope = current_scope.get()
  40. queries = {'query_fan_sensor_realtime', 'query_fan_equipment_realtime', 'query_fan_timeseries',
  41. 'query_fan_equipment_timeseries', 'fetch_main_fan_data', 'submit_main_fan_mine_plan',
  42. 'query_main_fan_history', 'fetch_main_fan_reports'}
  43. if scope is not None and scope.active_workflow_id and call['name'] in queries:
  44. from db import main_fan_store
  45. from json import dumps
  46. prepared = main_fan_store.workflow(scope.active_workflow_id, scope.user, scope.session)['context'].get('prepared_data')
  47. if prepared:
  48. # 本地批次的取数只由宿主执行;兼容旧提示中的批量工具调用,返回现成收据。
  49. receipt = (prepared['history'] if call['name'] == 'query_main_fan_history' else
  50. prepared['reports'] if call['name'] == 'fetch_main_fan_reports' else None)
  51. return ToolMessage(content=dumps(receipt or {'ok': False,
  52. 'message': '本批次已由固定代码完成测点发现、历史取数和数据整理。直接读取agent_data_path分析,无需重新规划取数。',
  53. 'agent_data_path': prepared['agent_data_path']}, ensure_ascii=False),
  54. tool_call_id=call['id'], name=call['name'], status='success' if receipt else 'error')
  55. if scope is not None and call['name'] in {'ask_user', 'request_plan_approval'}:
  56. return ToolMessage(content=(
  57. '当前是用户已授权的本地批次。使用本轮消息明确给出的workflow_id;前一阶段的ID已经归档。'
  58. '使用宿主已准备的监测数据和报告原件继续分析,数据归属不足时如实提交UNDETERMINED及限制,'
  59. '无需询问是否继续,不要调用人工确认工具。'
  60. ), tool_call_id=call['id'], name=call['name'], status='error')
  61. if call['name'] != 'read_file':
  62. return None
  63. path = call.get('args', {}).get('file_path', '')
  64. if not isinstance(path, str) or PurePosixPath(path).suffix.lower() != '.pdf':
  65. return None
  66. # 当前 OpenAI 兼容网关不接受框架的 PDF file 内容块。给模型可恢复反馈,
  67. # 不在此处隐式 OCR 或调用旧解析器,也不读取这份原件。
  68. return ToolMessage(content=(
  69. 'PDF 是二进制原件,当前模型接口不能直接接收 PDF 附件。请按 Skill 自己编写 Python,'
  70. '通过 run_python 使用 pypdfium2 取得页数和文本;扫描页渲染为 PNG,再用 read_file 查看 PNG。'
  71. '代码内将 /workspace/ 前缀移除,使用工作区相对路径。'
  72. ), tool_call_id=call['id'], name='read_file', status='error')
  73. class MainFanSandboxMiddleware(AgentMiddleware):
  74. def wrap_tool_call(self, request, handler):
  75. feedback = _pdf_read_feedback(request)
  76. if feedback is not None:
  77. return feedback
  78. response = handler(request)
  79. record_page_read(request, response)
  80. return response
  81. async def awrap_tool_call(self, request, handler):
  82. feedback = _pdf_read_feedback(request)
  83. if feedback is not None:
  84. return feedback
  85. response = await handler(request)
  86. record_page_read(request, response)
  87. return response