test_retrieval_adapters.py 20 KB

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