test_legacy_active_inputs.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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_lexically_preselects_at_most_twenty_before_llm(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. {
  183. "id": 100 + index,
  184. "title": f"unrelated-{index}",
  185. "purpose": "other material",
  186. "steps": [],
  187. }
  188. for index in range(40)
  189. ],
  190. ],
  191. ensure_ascii=False,
  192. ),
  193. encoding="utf-8",
  194. )
  195. bodies = []
  196. def handler(request: httpx.Request) -> httpx.Response:
  197. bodies.append(json.loads(request.content))
  198. return httpx.Response(
  199. 200,
  200. json={"choices": [{"message": {"content": '{"ids":[7]}'}}]},
  201. )
  202. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  203. safe = SafeHttpClient(
  204. client,
  205. OutboundPolicy(frozenset({"openrouter.example"}), resolver=_public_resolver),
  206. )
  207. openrouter = LegacyOpenRouterClient(
  208. safe,
  209. api_key="secret",
  210. chat_endpoint="https://openrouter.example/chat",
  211. )
  212. result = await LegacyLlmKnowledgeRetrievalAdapter(
  213. source, openrouter=openrouter, model="knowledge-model"
  214. ).retrieve(query={"keyword": "结构", "max_count": 3}, snapshot=SimpleNamespace())
  215. assert bodies[0]["model"] == "knowledge-model"
  216. assert "先给结果" in bodies[0]["messages"][1]["content"]
  217. assert bodies[0]["messages"][1]["content"].count('"id"') <= 20
  218. assert "unrelated-39" not in bodies[0]["messages"][1]["content"]
  219. assert "钩子" in result.summary
  220. @pytest.mark.asyncio
  221. async def test_legacy_external_reproduces_xhs_detail_and_zhihu_shapes() -> None:
  222. requests: list[tuple[str, dict[str, object]]] = []
  223. def handler(request: httpx.Request) -> httpx.Response:
  224. body = json.loads(request.content)
  225. requests.append((request.url.path, body))
  226. if request.url.path == "/xhs/search":
  227. return httpx.Response(
  228. 200,
  229. json={"code": 0, "data": {"data": [{"id": "note-1"}], "has_more": False}},
  230. )
  231. if request.url.path == "/xhs/detail":
  232. return httpx.Response(
  233. 200,
  234. json={
  235. "code": 0,
  236. "data": {
  237. "data": {
  238. "channel_content_id": "note-1",
  239. "title": "标题",
  240. "body_text": "正文",
  241. "like_count": "12",
  242. "publish_timestamp": 100,
  243. "image_url_list": [
  244. {"image_url": "https://image.example/a.jpg"},
  245. {"image_url": "https://image.example/a.jpg"},
  246. ],
  247. }
  248. },
  249. },
  250. )
  251. return httpx.Response(200, json={"code": 0, "data": [{"id": "zhihu-1"}]})
  252. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
  253. safe = SafeHttpClient(
  254. client,
  255. OutboundPolicy(frozenset({"legacy.example"}), resolver=_public_resolver),
  256. )
  257. adapter = LegacyExternalRetrievalAdapter(
  258. xhs_search_endpoint="https://legacy.example/xhs/search",
  259. xhs_detail_endpoint="https://legacy.example/xhs/detail",
  260. zhihu_search_endpoint="https://legacy.example/zhihu",
  261. http=safe,
  262. )
  263. xhs = await adapter.retrieve(
  264. query={"keyword": "测试", "platform_channel": "xhs", "max_count": 5},
  265. snapshot=SimpleNamespace(),
  266. )
  267. zhihu = await adapter.retrieve(
  268. query={"keyword": "测试", "platform_channel": "zhihu", "max_count": 5},
  269. snapshot=SimpleNamespace(),
  270. )
  271. xhs_rows = json.loads(xhs.summary)
  272. assert xhs_rows[0]["publish_timestamp"] == 100_000
  273. assert xhs_rows[0]["images"] == ["https://image.example/a.jpg"]
  274. assert xhs_rows[0]["images_text_escape"] == [
  275. {"image_url": "https://image.example/a.jpg", "text": ""}
  276. ]
  277. assert zhihu.source_refs == ("external:zhihu-1",)
  278. assert requests[0][1]["content_type"] == "图文"