test_legacy_active_inputs.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. from __future__ import annotations
  2. import json
  3. from types import SimpleNamespace
  4. import httpx
  5. import numpy as np
  6. import pytest
  7. from script_build_host.adapters.legacy_retrieval import (
  8. LegacyExternalRetrievalAdapter,
  9. LegacyLlmKnowledgeRetrievalAdapter,
  10. LegacyOpenRouterClient,
  11. LegacyVectorDecodeRetrievalAdapter,
  12. OpenAICompatibleEmbeddingClient,
  13. )
  14. from script_build_host.adapters.retrieval import SafeHttpClient
  15. from script_build_host.infrastructure.outbound import OutboundPolicy
  16. async def _public_resolver(_: str, __: int) -> tuple[str, ...]:
  17. return ("93.184.216.34",)
  18. def _decode_fixture(tmp_path):
  19. index = tmp_path / "index"
  20. raw = tmp_path / "raw" / "acct"
  21. index.mkdir()
  22. raw.mkdir(parents=True)
  23. (index / "manifest.json").write_text(
  24. json.dumps({"model": "embed", "dimension": 2}), encoding="utf-8"
  25. )
  26. chunks = [
  27. {
  28. "chunk_id": "p1|1",
  29. "post_id": "p1",
  30. "account": "acct",
  31. "json_relpath": "script_decode_raw_data/acct/p1.json",
  32. "column": "主题",
  33. "sub_field": "维度名",
  34. },
  35. {
  36. "chunk_id": "p2|1",
  37. "post_id": "p2",
  38. "account": "other",
  39. "json_relpath": "script_decode_raw_data/acct/p1.json",
  40. "column": "形式",
  41. "sub_field": "维度值",
  42. },
  43. ]
  44. (index / "meta.jsonl").write_text(
  45. "".join(json.dumps(item, ensure_ascii=False) + "\n" for item in chunks),
  46. encoding="utf-8",
  47. )
  48. np.save(index / "embeddings.npy", np.asarray([[1, 0], [0, 1]], dtype=np.float16))
  49. (raw / "p1.json").write_text(
  50. json.dumps(
  51. {
  52. "段落列表": [
  53. {
  54. "id": "段落1",
  55. "名称": "开头",
  56. "主题": "主题正文",
  57. "描述": "完整描述",
  58. "主题原子点": [{"维度类型": "主维度", "维度名": "对象", "维度值": "测试"}],
  59. }
  60. ]
  61. },
  62. ensure_ascii=False,
  63. ),
  64. encoding="utf-8",
  65. )
  66. return index, tmp_path / "raw"
  67. @pytest.mark.asyncio
  68. async def test_http_is_permitted_only_for_the_separate_legacy_allowlist() -> None:
  69. policy = OutboundPolicy(
  70. allowed_hosts=frozenset({"legacy.example"}),
  71. allowed_ports=frozenset({80}),
  72. allowed_http_hosts=frozenset({"legacy.example"}),
  73. resolver=_public_resolver,
  74. )
  75. assert await policy.validate_url("http://legacy.example/input") == (
  76. "http://legacy.example/input"
  77. )
  78. denied = OutboundPolicy(
  79. allowed_hosts=frozenset({"legacy.example"}),
  80. allowed_ports=frozenset({80}),
  81. resolver=_public_resolver,
  82. )
  83. with pytest.raises(Exception, match="HTTPS"):
  84. await denied.validate_url("http://legacy.example/input")
  85. @pytest.mark.asyncio
  86. async def test_legacy_decode_supports_account_only_and_semantic_vector_search(tmp_path) -> None:
  87. index, raw = _decode_fixture(tmp_path)
  88. def handler(request: httpx.Request) -> httpx.Response:
  89. assert request.headers["authorization"] == "Bearer secret"
  90. return httpx.Response(200, json={"data": [{"index": 0, "embedding": [1, 0]}]})
  91. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  92. safe = SafeHttpClient(
  93. client,
  94. OutboundPolicy(frozenset({"openrouter.example"}), resolver=_public_resolver),
  95. )
  96. embedding_client = OpenAICompatibleEmbeddingClient(
  97. safe,
  98. api_key="secret",
  99. embedding_endpoint="https://openrouter.example/embeddings",
  100. embedding_model="embed",
  101. embedding_dimension=2,
  102. )
  103. adapter = LegacyVectorDecodeRetrievalAdapter(
  104. index_root=index, raw_root=raw, embedding_client=embedding_client
  105. )
  106. account_result = await adapter.retrieve(
  107. query={
  108. "return_field": "主脉络",
  109. "keyword": "",
  110. "account_name": "acct",
  111. "match_fields": {},
  112. "top_k": 3,
  113. },
  114. snapshot=SimpleNamespace(),
  115. )
  116. semantic_result = await adapter.retrieve(
  117. query={
  118. "return_field": "主题",
  119. "keyword": "测试",
  120. "account_name": None,
  121. "match_fields": {"主题": "维度名"},
  122. "top_k": 3,
  123. },
  124. snapshot=SimpleNamespace(),
  125. )
  126. assert account_result.source_refs == ("decode:p1",)
  127. assert "主维度" in account_result.summary
  128. assert semantic_result.metadata["scores"] == [1.0]
  129. assert "主题正文" in semantic_result.summary
  130. @pytest.mark.asyncio
  131. async def test_legacy_decode_rejects_query_model_that_differs_from_index(tmp_path) -> None:
  132. index, raw = _decode_fixture(tmp_path)
  133. async with httpx.AsyncClient(
  134. transport=httpx.MockTransport(lambda _: httpx.Response(500))
  135. ) as client:
  136. safe = SafeHttpClient(
  137. client,
  138. OutboundPolicy(frozenset({"embedding.example"}), resolver=_public_resolver),
  139. )
  140. embedding_client = OpenAICompatibleEmbeddingClient(
  141. safe,
  142. api_key="secret",
  143. embedding_endpoint="https://embedding.example/embeddings",
  144. embedding_model="different-model",
  145. embedding_dimension=2,
  146. )
  147. adapter = LegacyVectorDecodeRetrievalAdapter(
  148. index_root=index,
  149. raw_root=raw,
  150. embedding_client=embedding_client,
  151. )
  152. with pytest.raises(Exception, match="model does not match"):
  153. await adapter.retrieve(
  154. query={
  155. "return_field": "主题",
  156. "keyword": "测试",
  157. "account_name": None,
  158. "match_fields": {},
  159. "top_k": 3,
  160. },
  161. snapshot=SimpleNamespace(),
  162. )
  163. @pytest.mark.asyncio
  164. async def test_legacy_knowledge_uses_full_llm_selection_contract(tmp_path) -> None:
  165. source = tmp_path / "knowledge.json"
  166. source.write_text(
  167. json.dumps(
  168. [
  169. {
  170. "id": 7,
  171. "title": "结构",
  172. "purpose": "搭建结构",
  173. "steps": [
  174. {
  175. "intent": "开头",
  176. "directive": "先给结果",
  177. "outputs": [{"value": "钩子"}],
  178. }
  179. ],
  180. }
  181. ],
  182. ensure_ascii=False,
  183. ),
  184. encoding="utf-8",
  185. )
  186. bodies = []
  187. def handler(request: httpx.Request) -> httpx.Response:
  188. bodies.append(json.loads(request.content))
  189. return httpx.Response(
  190. 200,
  191. json={"choices": [{"message": {"content": '{"ids":[7]}'}}]},
  192. )
  193. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  194. safe = SafeHttpClient(
  195. client,
  196. OutboundPolicy(frozenset({"openrouter.example"}), resolver=_public_resolver),
  197. )
  198. openrouter = LegacyOpenRouterClient(
  199. safe,
  200. api_key="secret",
  201. chat_endpoint="https://openrouter.example/chat",
  202. )
  203. result = await LegacyLlmKnowledgeRetrievalAdapter(
  204. source, openrouter=openrouter, model="knowledge-model"
  205. ).retrieve(query={"keyword": "结构", "max_count": 3}, snapshot=SimpleNamespace())
  206. assert bodies[0]["model"] == "knowledge-model"
  207. assert "先给结果" in bodies[0]["messages"][1]["content"]
  208. assert "钩子" in result.summary
  209. @pytest.mark.asyncio
  210. async def test_legacy_external_reproduces_xhs_detail_and_zhihu_shapes() -> None:
  211. requests: list[tuple[str, dict[str, object]]] = []
  212. def handler(request: httpx.Request) -> httpx.Response:
  213. body = json.loads(request.content)
  214. requests.append((request.url.path, body))
  215. if request.url.path == "/xhs/search":
  216. return httpx.Response(
  217. 200,
  218. json={"code": 0, "data": {"data": [{"id": "note-1"}], "has_more": False}},
  219. )
  220. if request.url.path == "/xhs/detail":
  221. return httpx.Response(
  222. 200,
  223. json={
  224. "code": 0,
  225. "data": {
  226. "data": {
  227. "channel_content_id": "note-1",
  228. "title": "标题",
  229. "body_text": "正文",
  230. "like_count": "12",
  231. "publish_timestamp": 100,
  232. "image_url_list": [
  233. {"image_url": "https://image.example/a.jpg"},
  234. {"image_url": "https://image.example/a.jpg"},
  235. ],
  236. }
  237. },
  238. },
  239. )
  240. return httpx.Response(200, json={"code": 0, "data": [{"id": "zhihu-1"}]})
  241. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  242. safe = SafeHttpClient(
  243. client,
  244. OutboundPolicy(frozenset({"legacy.example"}), resolver=_public_resolver),
  245. )
  246. adapter = LegacyExternalRetrievalAdapter(
  247. xhs_search_endpoint="https://legacy.example/xhs/search",
  248. xhs_detail_endpoint="https://legacy.example/xhs/detail",
  249. zhihu_search_endpoint="https://legacy.example/zhihu",
  250. http=safe,
  251. )
  252. xhs = await adapter.retrieve(
  253. query={"keyword": "测试", "platform_channel": "xhs", "max_count": 5},
  254. snapshot=SimpleNamespace(),
  255. )
  256. zhihu = await adapter.retrieve(
  257. query={"keyword": "测试", "platform_channel": "zhihu", "max_count": 5},
  258. snapshot=SimpleNamespace(),
  259. )
  260. xhs_rows = json.loads(xhs.summary)
  261. assert xhs_rows[0]["publish_timestamp"] == 100_000
  262. assert xhs_rows[0]["images"] == ["https://image.example/a.jpg"]
  263. assert xhs_rows[0]["images_text_escape"] == [
  264. {"image_url": "https://image.example/a.jpg", "text": ""}
  265. ]
  266. assert zhihu.source_refs == ("external:zhihu-1",)
  267. assert requests[0][1]["content_type"] == "图文"