test_retrieval_adapters.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. from __future__ import annotations
  2. import json
  3. from hashlib import sha256
  4. from types import SimpleNamespace
  5. import httpx
  6. import pytest
  7. from script_build_host.adapters.retrieval import (
  8. ExternalRetrievalAdapter,
  9. FileDecodeRetrievalAdapter,
  10. FileKnowledgeRetrievalAdapter,
  11. HttpDecodeRetrievalAdapter,
  12. PatternRetrievalAdapter,
  13. SafeHttpClient,
  14. SafeImageAdapter,
  15. )
  16. from script_build_host.adapters.uploaded_topic import SqlUploadedTopicGateway
  17. from script_build_host.domain.errors import ProtocolViolation, UnsafeOutboundTarget
  18. from script_build_host.infrastructure.outbound import OutboundPolicy
  19. from script_build_host.repositories.legacy_input import LegacySqlAlchemyInputReader
  20. async def _public_resolver(_: str, __: int) -> tuple[str, ...]:
  21. return ("93.184.216.34",)
  22. class _RawArtifacts:
  23. def __init__(self) -> None:
  24. self.values: list[tuple[str, bytes]] = []
  25. async def freeze_bytes(self, content: bytes, *, media_type: str) -> str:
  26. self.values.append((media_type, content))
  27. return "script-build://raw-artifacts/sha256/" + sha256(content).hexdigest()
  28. @pytest.mark.asyncio
  29. async def test_http_retrieval_revalidates_redirect_and_freezes_response_metadata() -> None:
  30. calls: list[str] = []
  31. def handler(request: httpx.Request) -> httpx.Response:
  32. calls.append(str(request.url))
  33. return httpx.Response(
  34. 200,
  35. json={"data": [{"channel_content_id": "post-1", "title": "case"}]},
  36. )
  37. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  38. safe = SafeHttpClient(
  39. client,
  40. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  41. )
  42. raw_store = _RawArtifacts()
  43. result = await ExternalRetrievalAdapter(
  44. "https://api.example.com/search", safe, raw_store
  45. ).retrieve(query={"keyword": "case"}, snapshot=SimpleNamespace())
  46. assert calls == ["https://api.example.com/search"]
  47. assert result.source_refs == ("external:post-1",)
  48. assert str(result.metadata["response_sha256"]).startswith("sha256:")
  49. assert result.raw_artifact_ref is not None
  50. assert result.raw_artifact_ref.endswith(
  51. str(result.metadata["response_sha256"]).removeprefix("sha256:")
  52. )
  53. assert raw_store.values[0][0] == "application/json"
  54. def redirect(_: httpx.Request) -> httpx.Response:
  55. return httpx.Response(302, headers={"location": "https://127.0.0.1/private"})
  56. async with httpx.AsyncClient(transport=httpx.MockTransport(redirect)) as client:
  57. safe = SafeHttpClient(
  58. client,
  59. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  60. )
  61. with pytest.raises(UnsafeOutboundTarget):
  62. await safe.request("GET", "https://api.example.com/start")
  63. def oversized(_: httpx.Request) -> httpx.Response:
  64. return httpx.Response(200, content=b"123456")
  65. async with httpx.AsyncClient(transport=httpx.MockTransport(oversized)) as client:
  66. safe = SafeHttpClient(
  67. client,
  68. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  69. max_response_bytes=5,
  70. )
  71. with pytest.raises(ProtocolViolation, match="byte limit"):
  72. await safe.request("GET", "https://api.example.com/large")
  73. @pytest.mark.asyncio
  74. async def test_external_response_secrets_are_redacted_before_evidence_summary() -> None:
  75. def handler(_request: httpx.Request) -> httpx.Response:
  76. return httpx.Response(
  77. 200,
  78. json={
  79. "data": [
  80. {
  81. "id": "p1",
  82. "authorization": "Bearer raw-token",
  83. "url": "https://source.example/item?token=secret",
  84. }
  85. ]
  86. },
  87. )
  88. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  89. safe = SafeHttpClient(
  90. client,
  91. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  92. )
  93. raw_store = _RawArtifacts()
  94. result = await ExternalRetrievalAdapter(
  95. "https://api.example.com/search", safe, raw_store
  96. ).retrieve(query={"keyword": "case"}, snapshot=SimpleNamespace())
  97. assert "raw-token" not in result.summary
  98. assert "secret" not in result.summary
  99. frozen = raw_store.values[0][1].decode("utf-8")
  100. assert "raw-token" not in frozen
  101. assert "secret" not in frozen
  102. @pytest.mark.asyncio
  103. async def test_external_large_response_keeps_bounded_summary_and_complete_raw_json() -> None:
  104. rows = [{"id": f"p{index}", "text": "x" * 2_000} for index in range(20)]
  105. def handler(_request: httpx.Request) -> httpx.Response:
  106. return httpx.Response(200, json={"data": rows})
  107. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  108. safe = SafeHttpClient(
  109. client,
  110. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  111. )
  112. raw_store = _RawArtifacts()
  113. result = await ExternalRetrievalAdapter(
  114. "https://api.example.com/search", safe, raw_store
  115. ).retrieve(query={"keyword": "case"}, snapshot=SimpleNamespace())
  116. assert len(result.summary) <= 20_000
  117. assert json.loads(result.summary)["truncated_for_context"] is True
  118. assert len(json.loads(raw_store.values[0][1])["data"]) == 20
  119. @pytest.mark.asyncio
  120. async def test_pattern_polling_and_image_limits_use_mock_transport() -> None:
  121. poll_count = 0
  122. def handler(request: httpx.Request) -> httpx.Response:
  123. nonlocal poll_count
  124. if request.url.path == "/pattern":
  125. return httpx.Response(
  126. 200,
  127. json={
  128. "status": "pending",
  129. "session_id": "task-1",
  130. "poll_url": "https://api.example.com/poll/task-1",
  131. },
  132. )
  133. if request.url.path.startswith("/poll"):
  134. poll_count += 1
  135. return httpx.Response(200, json={"status": "done", "results": [{"id": 2}]})
  136. return httpx.Response(
  137. 200,
  138. content=b"\x89PNG\r\n\x1a\nimage",
  139. headers={"content-type": "image/png"},
  140. )
  141. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  142. safe = SafeHttpClient(
  143. client,
  144. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  145. )
  146. raw_store = _RawArtifacts()
  147. result = await PatternRetrievalAdapter(
  148. "https://api.example.com/pattern",
  149. safe,
  150. raw_store,
  151. poll_interval_seconds=0.001,
  152. ).retrieve(query={"message": "shape"}, snapshot=SimpleNamespace())
  153. images = await SafeImageAdapter(
  154. safe,
  155. raw_store,
  156. max_images=1,
  157. max_image_bytes=20,
  158. max_total_image_bytes=20,
  159. ).load(urls=["https://api.example.com/image"], snapshot=SimpleNamespace())
  160. assert poll_count == 1
  161. assert result.source_refs == ("pattern:2",)
  162. assert result.raw_artifact_ref is not None
  163. assert images[0]["digest"].startswith("sha256:")
  164. assert "url" not in images[0]
  165. assert images[0]["raw_artifact_ref"].startswith("script-build://raw-artifacts/")
  166. assert [media_type for media_type, _ in raw_store.values] == [
  167. "application/json",
  168. "image/png",
  169. ]
  170. @pytest.mark.asyncio
  171. async def test_pattern_timeout_is_frozen_as_a_limitation() -> None:
  172. def handler(_request: httpx.Request) -> httpx.Response:
  173. return httpx.Response(200, json={"status": "pending", "session_id": "task-1"})
  174. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  175. safe = SafeHttpClient(
  176. client,
  177. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  178. )
  179. result = await PatternRetrievalAdapter(
  180. "https://api.example.com/pattern",
  181. safe,
  182. _RawArtifacts(),
  183. poll_interval_seconds=0.001,
  184. poll_timeout_seconds=0,
  185. ).retrieve(query={"message": "shape"}, snapshot=SimpleNamespace())
  186. assert result.source_refs == ()
  187. assert result.limitations == ("timeout",)
  188. assert result.metadata["task_id"] == "task-1"
  189. @pytest.mark.asyncio
  190. async def test_pattern_result_keeps_five_candidates_and_freezes_all_items() -> None:
  191. candidates = [
  192. {
  193. "itemset": [{"dimension": f"dimension-{index}"}],
  194. "target_items": [{"dimension": f"dimension-{index}"}],
  195. "support": 10 - index,
  196. }
  197. for index in range(6)
  198. ]
  199. async def handler(_request: httpx.Request) -> httpx.Response:
  200. return httpx.Response(
  201. 200,
  202. json=[{"status": "success", "data": {"candidates": candidates}}],
  203. )
  204. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  205. safe = SafeHttpClient(
  206. client,
  207. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  208. )
  209. raw_store = _RawArtifacts()
  210. result = await PatternRetrievalAdapter(
  211. "https://api.example.com/pattern", safe, raw_store
  212. ).retrieve(query={"message": "shape"}, snapshot=SimpleNamespace())
  213. payload = json.loads(result.summary)[0]
  214. assert payload["data"]["candidates_total"] == 6
  215. assert payload["data"]["candidates_returned"] == 5
  216. assert len(payload["data"]["candidates"]) == 5
  217. assert all("target_items" not in item for item in payload["data"]["candidates"])
  218. frozen = json.loads(raw_store.values[0][1])
  219. assert len(frozen[0]["data"]["candidates"]) == 6
  220. assert result.raw_artifact_ref is not None
  221. @pytest.mark.asyncio
  222. async def test_pattern_result_has_a_hard_context_size_limit() -> None:
  223. candidate = {
  224. "itemset": [{"dimension": "x" * 10_000}],
  225. "target_items": [{"dimension": "x" * 10_000}],
  226. "examples": ["post-1"],
  227. }
  228. async def handler(_request: httpx.Request) -> httpx.Response:
  229. return httpx.Response(
  230. 200,
  231. json=[
  232. {
  233. "status": "success",
  234. "output": "large result",
  235. "data": {"candidates_total": 193, "candidates": [candidate] * 20},
  236. }
  237. ],
  238. )
  239. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  240. safe = SafeHttpClient(
  241. client,
  242. OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
  243. )
  244. raw_store = _RawArtifacts()
  245. result = await PatternRetrievalAdapter(
  246. "https://api.example.com/pattern", safe, raw_store
  247. ).retrieve(query={"message": "shape"}, snapshot=SimpleNamespace())
  248. assert len(result.summary) <= 20_100
  249. payload = json.loads(result.summary)[0]
  250. assert payload["data"]["candidates_total"] == 193
  251. assert payload["data"]["truncated_for_context"] is True
  252. assert len(raw_store.values[0][1]) > len(result.summary.encode("utf-8"))
  253. @pytest.mark.asyncio
  254. async def test_file_decode_and_knowledge_freeze_file_hash_and_rank(tmp_path) -> None:
  255. decode_path = tmp_path / "decode.json"
  256. decode_path.write_text(
  257. json.dumps({"items": [{"post_id": 4, "account": "acct", "text": "focus"}]}),
  258. encoding="utf-8",
  259. )
  260. knowledge_path = tmp_path / "knowledge.json"
  261. knowledge_path.write_text(json.dumps([{"id": "k1", "title": "focus method"}]), encoding="utf-8")
  262. decode_adapter = FileDecodeRetrievalAdapter(decode_path)
  263. snapshot = SimpleNamespace(
  264. model_manifest={"embedding_model": "frozen-embed"},
  265. datasource_manifest={},
  266. )
  267. decode = await decode_adapter.retrieve(
  268. query={
  269. "keyword": "focus",
  270. "account_name": "acct",
  271. "top_k": 3,
  272. "return_field": "主脉络",
  273. },
  274. snapshot=snapshot,
  275. )
  276. knowledge = await FileKnowledgeRetrievalAdapter(knowledge_path).retrieve(
  277. query={"keyword": "focus", "max_count": 3}, snapshot=snapshot
  278. )
  279. assert decode.metadata["embedding_model"] == "frozen-embed"
  280. assert decode.metadata["ranks"] == [1]
  281. assert decode.metadata["scores"] == [1.0]
  282. assert str(knowledge.metadata["file_sha256"]).startswith("sha256:")
  283. frozen_digest = decode.metadata["index_sha256"]
  284. decode_path.write_text(json.dumps({"items": []}), encoding="utf-8")
  285. replay = await decode_adapter.retrieve(
  286. query={"keyword": "focus", "top_k": 3, "return_field": "主脉络"},
  287. snapshot=SimpleNamespace(
  288. model_manifest={},
  289. datasource_manifest={"decode_index": {"sha256": frozen_digest}},
  290. ),
  291. )
  292. assert replay.source_refs == ("decode:4",)
  293. with pytest.raises(ProtocolViolation, match="changed"):
  294. await FileDecodeRetrievalAdapter(decode_path).retrieve(
  295. query={"keyword": "focus", "top_k": 3, "return_field": "主脉络"},
  296. snapshot=SimpleNamespace(
  297. model_manifest={},
  298. datasource_manifest={"decode_index": {"sha256": frozen_digest}},
  299. ),
  300. )
  301. @pytest.mark.asyncio
  302. async def test_http_decode_preserves_legacy_query_and_ranked_scores() -> None:
  303. requests: list[dict[str, object]] = []
  304. def handler(request: httpx.Request) -> httpx.Response:
  305. requests.append(json.loads(request.content))
  306. return httpx.Response(
  307. 200,
  308. json={"items": [{"post_id": "p1", "score": 0.91, "text": "safe"}]},
  309. )
  310. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  311. safe = SafeHttpClient(
  312. client,
  313. OutboundPolicy(frozenset({"decode.example"}), resolver=_public_resolver),
  314. )
  315. raw_store = _RawArtifacts()
  316. result = await HttpDecodeRetrievalAdapter(
  317. "https://decode.example/search",
  318. safe,
  319. raw_store,
  320. ).retrieve(
  321. query={
  322. "return_field": "主脉络",
  323. "keyword": "focus",
  324. "account_name": "acct",
  325. "match_fields": {},
  326. "top_k": 3,
  327. },
  328. snapshot=SimpleNamespace(
  329. datasource_manifest={"decode_index": {"sha256": "sha256:" + "1" * 64}},
  330. model_manifest={"embedding_model": "embed-v1"},
  331. ),
  332. )
  333. assert requests[0]["top_k"] == 3
  334. assert result.source_refs == ("decode:p1",)
  335. assert result.metadata["scores"] == [0.91]
  336. assert result.metadata["ranks"] == [1]
  337. assert json.loads(raw_store.values[0][1])["items"][0]["post_id"] == "p1"
  338. assert result.raw_artifact_ref is not None
  339. @pytest.mark.asyncio
  340. async def test_uploaded_topic_parser_keeps_old_shape_without_writing() -> None:
  341. gateway = SqlUploadedTopicGateway(SimpleNamespace()) # type: ignore[arg-type]
  342. parsed = await gateway.parse(
  343. {
  344. "选题融合": "topic",
  345. "target_post": {"channel_account_name": "acct"},
  346. "灵感点": [
  347. {
  348. "点": "point",
  349. "实质": {"具体元素": [{"名称": "element", "说明": "note"}]},
  350. }
  351. ],
  352. }
  353. )
  354. assert parsed["account_name"] == "acct"
  355. assert parsed["item_count"] == 1
  356. assert parsed["points"][0]["items"][0]["dimension"] == "实质"
  357. @pytest.mark.asyncio
  358. async def test_uploaded_topic_create_writes_a_readable_legacy_graph(database) -> None:
  359. _, sessions = database
  360. gateway = SqlUploadedTopicGateway(sessions)
  361. parsed = await gateway.parse(
  362. {
  363. "选题融合": "uploaded topic",
  364. "target_post": {"channel_account_name": "acct"},
  365. "关键点": [
  366. {
  367. "点": "point",
  368. "形式": [{"名称": "element", "说明": "note"}],
  369. }
  370. ],
  371. }
  372. )
  373. created = await gateway.create(parsed, account_name=None)
  374. graph = await LegacySqlAlchemyInputReader(sessions).read_topic_graph(
  375. execution_id=0,
  376. topic_build_id=created["topic_build_id"],
  377. topic_id=created["topic_id"],
  378. )
  379. assert graph["execution"] == {"id": 0, "status": "upload"}
  380. assert graph["topic"]["result"] == "uploaded topic"
  381. assert graph["points"][0]["item_ids"] == [graph["composition_items"][0]["id"]]