test_decode_index_builder.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. from hashlib import sha256
  5. from pathlib import Path
  6. import httpx
  7. import numpy as np
  8. import pytest
  9. from script_build_host.infrastructure.decode_index_builder import (
  10. DashScopeEmbeddingClient,
  11. EmbeddingApiError,
  12. extract_decode_chunks,
  13. rebuild_decode_index,
  14. )
  15. def _raw_fixture(root: Path) -> Path:
  16. raw = root / "script_decode_raw_data" / "账号甲"
  17. raw.mkdir(parents=True)
  18. (raw / "post-1.json").write_text(
  19. json.dumps(
  20. {
  21. "帖子ID": "post-1",
  22. "段落列表": [
  23. {
  24. "id": "p1",
  25. "名称": "开头",
  26. "主题原子点": [
  27. {
  28. "维度名": "对象",
  29. "维度名描述": "面向谁",
  30. "维度值": "创业者",
  31. "维度值描述": "早期团队",
  32. }
  33. ],
  34. "子项": [
  35. {
  36. "id": "p1.1",
  37. "名称": "子段",
  38. "形式原子点": [{"维度": "表达", "原子点": "口播"}],
  39. }
  40. ],
  41. }
  42. ],
  43. },
  44. ensure_ascii=False,
  45. ),
  46. encoding="utf-8",
  47. )
  48. return raw.parent
  49. def _sha256(path: Path) -> str:
  50. return sha256(path.read_bytes()).hexdigest()
  51. def test_extract_decode_chunks_matches_legacy_fields_and_nested_paragraphs(
  52. tmp_path: Path,
  53. ) -> None:
  54. raw_root = _raw_fixture(tmp_path)
  55. chunks = extract_decode_chunks(raw_root)
  56. assert len(chunks) == 6
  57. assert [chunk["text_field"] for chunk in chunks] == [
  58. "维度名",
  59. "维度名描述",
  60. "维度值",
  61. "维度值描述",
  62. "维度",
  63. "原子点",
  64. ]
  65. assert chunks[-1]["paragraph_id"] == "p1.1"
  66. assert chunks[-1]["json_relpath"] == "script_decode_raw_data/账号甲/post-1.json"
  67. @pytest.mark.asyncio
  68. async def test_rebuild_decode_index_is_complete_resumable_and_atomic(tmp_path: Path) -> None:
  69. raw_root = _raw_fixture(tmp_path)
  70. source_path = raw_root / "账号甲" / "post-1.json"
  71. source_before = _sha256(source_path)
  72. index_root = tmp_path / "index"
  73. index_root.mkdir()
  74. (index_root / "old-marker").write_text("old", encoding="utf-8")
  75. request_sizes: list[int] = []
  76. def handler(request: httpx.Request) -> httpx.Response:
  77. payload = json.loads(request.content)
  78. texts = payload["input"]
  79. request_sizes.append(len(texts))
  80. data = [
  81. {"index": index, "embedding": [1.0, float(index + 1), 0.5, 0.25]}
  82. for index in reversed(range(len(texts)))
  83. ]
  84. return httpx.Response(
  85. 200,
  86. json={"model": "text-embedding-v4", "data": data, "usage": {"prompt_tokens": 7}},
  87. )
  88. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
  89. client = DashScopeEmbeddingClient(
  90. http,
  91. api_key="secret",
  92. endpoint="https://dashscope.example/embeddings",
  93. model="text-embedding-v4",
  94. dimension=4,
  95. concurrency=2,
  96. )
  97. result = await rebuild_decode_index(
  98. raw_root=raw_root,
  99. index_root=index_root,
  100. client=client,
  101. batch_size=2,
  102. shard_size=4,
  103. )
  104. assert request_sizes == [2, 2, 2]
  105. assert not (index_root / "old-marker").exists()
  106. assert _sha256(source_path) == source_before
  107. assert result.manifest["provider"] == "aliyun-bailian"
  108. assert result.manifest["chunk_count"] == 6
  109. assert result.manifest["request_count"] == 3
  110. assert result.manifest["total_tokens"] == 21
  111. assert np.load(index_root / "embeddings.npy").shape == (6, 4)
  112. assert len((index_root / "meta.jsonl").read_text(encoding="utf-8").splitlines()) == 6
  113. assert not await asyncio.to_thread(lambda: list(tmp_path.glob(".index-build-*")))
  114. @pytest.mark.asyncio
  115. async def test_failed_rebuild_keeps_previously_published_index(tmp_path: Path) -> None:
  116. raw_root = _raw_fixture(tmp_path)
  117. index_root = tmp_path / "index"
  118. index_root.mkdir()
  119. (index_root / "old-marker").write_text("old", encoding="utf-8")
  120. def handler(_: httpx.Request) -> httpx.Response:
  121. return httpx.Response(400, json={"message": "bad request"})
  122. async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
  123. client = DashScopeEmbeddingClient(
  124. http,
  125. api_key="secret",
  126. endpoint="https://dashscope.example/embeddings",
  127. model="text-embedding-v4",
  128. dimension=4,
  129. max_retries=0,
  130. )
  131. with pytest.raises(EmbeddingApiError, match="HTTP 400"):
  132. await rebuild_decode_index(
  133. raw_root=raw_root,
  134. index_root=index_root,
  135. client=client,
  136. batch_size=2,
  137. shard_size=4,
  138. )
  139. assert (index_root / "old-marker").read_text(encoding="utf-8") == "old"
  140. assert await asyncio.to_thread(lambda: list(tmp_path.glob(".index-build-*")))