workspace_paths.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """会话工作区路径解析:让工具能读写当前会话沙箱里的文件。
  2. 工具收到的路径是模型看到的**虚拟路径**(如 `/workspace/uploads/xxx.pdf`)。要打开
  3. 真实文件,需要把虚拟路径映射回 `data/workspaces/<user_hash>/<session_hash>/`。
  4. 会话身份由运行层写入图配置的 `configurable`,这里用 langgraph 的 get_config 取回。
  5. 为什么在这里重写哈希映射而不是直接 import `agents.workspace.session_root`:分层纪律
  6. 要求工具侧不依赖 agents 层。风险是两处映射漂移,因此 `tests/test_airflow_tools.py`
  7. 里有一条断言两者结果一致的测试——漂移会变成失败的测试,而不是线上找不到文件。
  8. """
  9. from __future__ import annotations
  10. import hashlib
  11. from pathlib import Path
  12. import config
  13. from algorithms.airflow_review.contracts import now_iso
  14. class WorkspacePathError(ValueError):
  15. """无法确定会话沙箱或路径越界。"""
  16. def workspace_root() -> Path | None:
  17. """取当前会话的沙箱根;不在运行上下文里(CLI/测试)返回 None。"""
  18. try:
  19. from langgraph.config import get_config
  20. configurable = (get_config() or {}).get("configurable", {}) or {}
  21. except Exception:
  22. return None
  23. user, session = configurable.get("workspace_user"), configurable.get("workspace_session")
  24. if not user or not session:
  25. return None
  26. return session_root(str(user), str(session))
  27. def session_root(user_name: str, session_id: str) -> Path:
  28. """与 agents.workspace.session_root 保持一致的映射(见模块说明)。"""
  29. user_key = hashlib.sha256(user_name.encode()).hexdigest()
  30. session_key = hashlib.sha256(session_id.encode()).hexdigest()
  31. return config.ROOT / "data" / "workspaces" / user_key / session_key
  32. def resolve_input(path: str, *, allow_repo_relative: bool = True) -> Path:
  33. """把模型给的路径解析成可读的真实文件路径。
  34. 支持三种写法:会话虚拟路径(/workspace/...)、项目内相对路径(便于脚本与测试)、
  35. 绝对路径(要求文件存在)。越出沙箱的路径一律拒绝。
  36. """
  37. if not path or not str(path).strip():
  38. raise WorkspacePathError("文件路径为空")
  39. raw = str(path).strip().replace("\\", "/")
  40. root = workspace_root()
  41. if raw.startswith("/workspace/") or raw.startswith("/conversation_history/"):
  42. if root is None:
  43. raise WorkspacePathError(
  44. "该路径是会话工作区路径,但当前不在会话运行上下文中,无法定位沙箱;"
  45. "请在对话中调用,或改用真实文件路径")
  46. target = (root / raw.lstrip("/")).resolve()
  47. if root.resolve() not in target.parents and target != root.resolve():
  48. raise WorkspacePathError(f"路径越出会话沙箱:{path}")
  49. return target
  50. candidate = Path(raw)
  51. if candidate.is_absolute():
  52. if not candidate.is_file():
  53. raise WorkspacePathError(f"文件不存在:{candidate}")
  54. return candidate
  55. if allow_repo_relative:
  56. repo_relative = (config.ROOT / raw).resolve()
  57. if repo_relative.is_file():
  58. return repo_relative
  59. raise WorkspacePathError(f"无法解析文件路径:{path}")
  60. def output_path(name: str, *, suffix: str = ".json") -> tuple[Path, str]:
  61. """为本次工具调用分配一个输出文件;返回 (真实路径, 虚拟路径)。
  62. 没有会话上下文时落到项目 data/airflow_outputs/,保证脚本与测试也能跑。
  63. """
  64. safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name)[:60]
  65. stamp = now_iso().replace(":", "").replace("-", "").replace("+", "")[:15]
  66. filename = f"{stamp}-{safe}{suffix}"
  67. root = workspace_root()
  68. if root is None:
  69. directory = config.resolve_path(config.AIRFLOW_OUTPUT_DIR
  70. or f"data/{config.AIRFLOW_OUTPUT_DIRNAME}")
  71. directory.mkdir(parents=True, exist_ok=True)
  72. return directory / filename, str(directory / filename)
  73. directory = root / "workspace" / "outputs" / config.AIRFLOW_OUTPUT_DIRNAME
  74. directory.mkdir(parents=True, exist_ok=True)
  75. relative = f"/workspace/outputs/{config.AIRFLOW_OUTPUT_DIRNAME}/{filename}"
  76. return directory / filename, relative
  77. def write_artifact(payload: str | bytes, name: str, *, suffix: str = ".json") -> str:
  78. """写中间产物并返回模型可读的虚拟路径(无会话上下文时返回真实路径)。"""
  79. path, virtual = output_path(name, suffix=suffix)
  80. if isinstance(payload, bytes):
  81. path.write_bytes(payload)
  82. else:
  83. path.write_text(payload, encoding="utf-8")
  84. return virtual