test_segment_media_publishing.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import sys
  5. import tempfile
  6. import unittest
  7. from pathlib import Path
  8. from types import SimpleNamespace
  9. from unittest.mock import patch
  10. from langchain_core.messages import AIMessage, ToolMessage
  11. from production_build_agents.agents.tool_trace import build_tool_call_records
  12. from production_build_agents.run.operation_journal import (
  13. OperationOutcomeUnknownError,
  14. )
  15. from production_build_agents.tools import publishing
  16. from production_build_agents.tools.publishing import (
  17. PublishingToolError,
  18. publish_media_reference,
  19. require_media_reference_publishing,
  20. )
  21. from production_build_agents.tools.registry import create_default_tool_registry
  22. _OSS_ENV = {
  23. "ALIYUN_OSS_ACCESS_KEY_ID": "id",
  24. "ALIYUN_OSS_ACCESS_KEY_SECRET": "secret",
  25. "ALIYUN_OSS_ENDPOINT": "https://oss.test",
  26. "ALIYUN_OSS_BUCKET": "bucket",
  27. "ALIYUN_OSS_CDN_BASE_URL": "https://cdn.test",
  28. }
  29. class _Download:
  30. def __init__(self, content: bytes) -> None:
  31. self.content = content
  32. def raise_for_status(self) -> None:
  33. return None
  34. def iter_content(self, _: int) -> list[bytes]:
  35. return [self.content]
  36. class SegmentMediaPublishingTest(unittest.TestCase):
  37. def test_verified_mapping_is_extracted_into_tool_trace(self) -> None:
  38. with tempfile.TemporaryDirectory() as temp_dir:
  39. local_path = str((Path(temp_dir) / "anchor.png").resolve())
  40. Path(local_path).write_bytes(b"anchor")
  41. records = build_tool_call_records(
  42. [
  43. AIMessage(
  44. content="",
  45. tool_calls=[
  46. {
  47. "id": "publish-1",
  48. "name": "publish_media_reference",
  49. "args": {"local_path": local_path},
  50. }
  51. ],
  52. ),
  53. ToolMessage(
  54. tool_call_id="publish-1",
  55. content=json.dumps(
  56. {
  57. "success": True,
  58. "local_path": local_path,
  59. "published_url": "https://cdn.test/anchor.png",
  60. "content_sha256": "a" * 64,
  61. }
  62. ),
  63. ),
  64. ]
  65. )
  66. self.assertEqual(len(records[0].artifact_mappings), 1)
  67. self.assertEqual(
  68. records[0].artifact_mappings[0].source,
  69. "https://cdn.test/anchor.png",
  70. )
  71. self.assertEqual(
  72. records[0].artifact_mappings[0].local_path,
  73. local_path,
  74. )
  75. def test_content_hash_produces_stable_key_and_verified_mapping(self) -> None:
  76. uploads: list[tuple[str, str]] = []
  77. class Bucket:
  78. def put_object_from_file(self, key: str, source: str) -> None:
  79. uploads.append((key, source))
  80. fake_oss = SimpleNamespace(
  81. Auth=lambda *_: object(),
  82. Bucket=lambda *_: Bucket(),
  83. )
  84. with tempfile.TemporaryDirectory() as temp_dir:
  85. path = Path(temp_dir) / "anchor.png"
  86. path.write_bytes(b"verified-anchor")
  87. digest = hashlib.sha256(path.read_bytes()).hexdigest()
  88. with (
  89. patch.dict("os.environ", _OSS_ENV, clear=False),
  90. patch.dict(sys.modules, {"oss2": fake_oss}),
  91. patch.object(
  92. publishing._SESSION,
  93. "get",
  94. return_value=_Download(path.read_bytes()),
  95. ),
  96. ):
  97. first = publish_media_reference(
  98. path,
  99. category="segment anchor",
  100. )
  101. second = publish_media_reference(
  102. path,
  103. category="segment anchor",
  104. )
  105. self.assertEqual(first, second)
  106. self.assertEqual(first["local_path"], str(path.resolve()))
  107. self.assertEqual(first["content_sha256"], digest)
  108. self.assertEqual(uploads[0][0], uploads[1][0])
  109. self.assertIn(f"/segment-anchor/{digest}.png", first["published_url"])
  110. def test_missing_oss_configuration_has_no_local_fallback(self) -> None:
  111. with tempfile.TemporaryDirectory() as temp_dir:
  112. root = Path(temp_dir)
  113. path = root / "anchor.png"
  114. path.write_bytes(b"anchor")
  115. with patch.dict(
  116. "os.environ",
  117. {name: "" for name in _OSS_ENV},
  118. clear=False,
  119. ):
  120. registry = create_default_tool_registry(output_dir=root)
  121. result = registry.resolve(
  122. ("publish_media_reference",)
  123. )[0].invoke({"local_path": str(path)})
  124. self.assertFalse(result["success"])
  125. self.assertIn("ALIYUN_OSS_", result["error"])
  126. self.assertNotIn("published_url", result)
  127. self.assertNotEqual(result.get("published_url"), str(path.resolve()))
  128. def test_publishing_preflight_requires_all_formal_oss_settings(self) -> None:
  129. with (
  130. patch.dict(
  131. "os.environ",
  132. {name: "" for name in _OSS_ENV},
  133. clear=False,
  134. ),
  135. self.assertRaisesRegex(
  136. PublishingToolError,
  137. "ALIYUN_OSS_ACCESS_KEY_ID",
  138. ),
  139. ):
  140. require_media_reference_publishing()
  141. def test_downloaded_bytes_must_match_uploaded_file(self) -> None:
  142. class Bucket:
  143. def put_object_from_file(self, *_: str) -> None:
  144. return None
  145. fake_oss = SimpleNamespace(
  146. Auth=lambda *_: object(),
  147. Bucket=lambda *_: Bucket(),
  148. )
  149. with tempfile.TemporaryDirectory() as temp_dir:
  150. path = Path(temp_dir) / "anchor.png"
  151. path.write_bytes(b"expected")
  152. with (
  153. patch.dict("os.environ", _OSS_ENV, clear=False),
  154. patch.dict(sys.modules, {"oss2": fake_oss}),
  155. patch.object(
  156. publishing._SESSION,
  157. "get",
  158. return_value=_Download(b"replaced"),
  159. ),
  160. self.assertRaisesRegex(PublishingToolError, "内容与本地"),
  161. ):
  162. publish_media_reference(path, category="anchor")
  163. def test_unknown_upload_outcome_is_not_resent_after_restart(self) -> None:
  164. attempts = 0
  165. class Bucket:
  166. def put_object_from_file(self, *_: str) -> None:
  167. nonlocal attempts
  168. attempts += 1
  169. raise TimeoutError("response lost")
  170. fake_oss = SimpleNamespace(
  171. Auth=lambda *_: object(),
  172. Bucket=lambda *_: Bucket(),
  173. )
  174. with tempfile.TemporaryDirectory() as temp_dir:
  175. root = Path(temp_dir)
  176. path = root / "anchor.png"
  177. path.write_bytes(b"anchor")
  178. kwargs = {
  179. "output_dir": root / "outputs",
  180. "run_dir": root,
  181. "executor_run_id": "segment-publish-v1",
  182. }
  183. with (
  184. patch.dict("os.environ", _OSS_ENV, clear=False),
  185. patch.dict(sys.modules, {"oss2": fake_oss}),
  186. ):
  187. first = create_default_tool_registry(**kwargs)
  188. with self.assertRaises(OperationOutcomeUnknownError):
  189. first.resolve(("publish_media_reference",))[0].invoke(
  190. {"local_path": str(path), "category": "anchor"}
  191. )
  192. restarted = create_default_tool_registry(**kwargs)
  193. with self.assertRaises(OperationOutcomeUnknownError):
  194. restarted.resolve(
  195. ("publish_media_reference",)
  196. )[0].invoke(
  197. {"local_path": str(path), "category": "anchor"}
  198. )
  199. self.assertEqual(attempts, 1)
  200. if __name__ == "__main__":
  201. unittest.main()