knowledge_recovery_acceptance.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import asyncio
  2. import json
  3. import sys
  4. from pathlib import Path
  5. from uuid import uuid4
  6. import pytest
  7. from test_after_sales import scenario # noqa: F401
  8. from test_knowledge_memory import cleanup_vectors, document
  9. from zhifu.modules.knowledge.config import KnowledgeSettings
  10. from zhifu.modules.knowledge.recovery import rebuild, snapshot, verify_source
  11. from zhifu.modules.knowledge.vector import connection, retrieval_client
  12. async def test_rebuild_published_documents_and_current_permissions(scenario): # noqa: F811
  13. target_name = "zhifu_recovery_" + uuid4().hex
  14. source = KnowledgeSettings()
  15. target = source.model_copy(update={"collection": target_name})
  16. cli_name = target_name + "_cli"
  17. concurrent_name = target_name + "_concurrent"
  18. call, app = scenario["call"], scenario["app"]
  19. original = app.state.knowledge
  20. paths = []
  21. try:
  22. paths.append(await document(scenario))
  23. for index in range(3):
  24. path = f"/admin/v1/stores/{scenario['store']}/knowledge/{uuid4()}"
  25. response = await call("PUT", path, "agent", {
  26. "expected_version": 0, "title": f"键盘恢复资料{index}",
  27. "text": f"键盘恢复资料{index}:检查接口连接,清洁前断开电源。",
  28. })
  29. assert response.status_code == 200, response.text
  30. paths.append(path)
  31. # 两份发布资料、一份草稿、一份撤回资料,来源均通过真实管理接口建立。
  32. for path in paths[:2] + paths[3:]:
  33. response = await call("POST", path + "/publish", "agent", {"expected_version": 1})
  34. assert response.status_code == 200, response.text
  35. response = await call("POST", paths[3] + "/revoke", "agent", {"expected_version": 1})
  36. assert response.status_code == 200, response.text
  37. with pytest.raises(ValueError, match="数量超过"):
  38. await rebuild(app.state.sessions, target_name, tenant_id=scenario["tenant"].bytes,
  39. maximum_documents=1)
  40. with pytest.raises(ValueError, match="当前业务集合|前缀"):
  41. await rebuild(app.state.sessions, source.collection)
  42. async with asyncio.timeout(120):
  43. report = await rebuild(
  44. app.state.sessions, target_name, tenant_id=scenario["tenant"].bytes,
  45. maximum_documents=4, maximum_chunks=10, timeout_seconds=90,
  46. )
  47. assert report["documents_scanned"] == 4
  48. assert report["documents_restored"] == report["chunks_restored"] == 2
  49. process = await asyncio.create_subprocess_exec(
  50. sys.executable, "-m", "zhifu.modules.knowledge.recovery",
  51. "--target-collection", cli_name, "--tenant-id", str(scenario["tenant"]),
  52. "--maximum-documents", "4", "--maximum-chunks", "10",
  53. "--timeout-seconds", "30", "--output", ".work/knowledge-recovery-cli.json",
  54. stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
  55. )
  56. try:
  57. async with asyncio.timeout(45):
  58. output, error = await process.communicate()
  59. finally:
  60. if process.returncode is None:
  61. process.kill()
  62. await process.wait()
  63. assert process.returncode == 0, error.decode("utf-8", errors="replace")
  64. cli_report = json.loads(output)
  65. assert cli_report["status"] == "VERIFIED" and cli_report["documents_restored"] == 2
  66. assert cli_report["source_sha256"] == report["source_sha256"]
  67. with pytest.raises(ValueError, match="已存在"):
  68. await rebuild(app.state.sessions, target_name, tenant_id=scenario["tenant"].bytes)
  69. async with asyncio.timeout(90):
  70. concurrent_results = await asyncio.gather(*(
  71. rebuild(app.state.sessions, concurrent_name,
  72. tenant_id=scenario["tenant"].bytes,
  73. maximum_documents=4, maximum_chunks=10, timeout_seconds=60)
  74. for _ in range(2)
  75. ), return_exceptions=True)
  76. assert sum(isinstance(result, dict) for result in concurrent_results) == 1
  77. rejected = [result for result in concurrent_results if isinstance(result, ValueError)]
  78. assert len(rejected) == 1
  79. assert "已存在" in str(rejected[0]) or "其他进程创建" in str(rejected[0])
  80. async with retrieval_client(target) as recovered:
  81. app.state.knowledge = recovered
  82. path = f"/openapi/v1/stores/{scenario['store']}/knowledge/search"
  83. response = await call("POST", path, payload={"query": "键盘清洁断电"})
  84. assert response.status_code == 200, response.text
  85. assert {item["document_id"] for item in response.json()["items"]} == {
  86. value.rsplit("/", 1)[1] for value in paths[:2]
  87. }
  88. assert (await call("POST", path, "foreign", {"query": "键盘"})).status_code == 404
  89. _, source_digest = await snapshot(app.state.sessions, scenario["tenant"].bytes, 4)
  90. response = await call("POST", paths[0] + "/revoke", "agent", {"expected_version": 1})
  91. assert response.status_code == 200, response.text
  92. with pytest.raises(ValueError, match="源资料发生变化"):
  93. await verify_source(app.state.sessions, scenario["tenant"].bytes, 4, source_digest)
  94. response = await call("PUT", paths[1], "agent", {
  95. "expected_version": 1, "title": "修订资料", "text": "修订后等待重新发布。",
  96. })
  97. assert response.status_code == 200, response.text
  98. response = await call("POST", path, payload={"query": "键盘"})
  99. assert response.status_code == 200 and response.json()["items"] == []
  100. report["checks"] = {
  101. "draft_and_revoked_excluded": True, "foreign_tenant_denied": True,
  102. "current_revision_and_revocation_checked": True, "existing_target_denied": True,
  103. "active_target_denied": True, "document_limit_enforced": True,
  104. "cli_rebuild_verified": True,
  105. "same_target_concurrency_denied": True, "source_change_detected": True,
  106. }
  107. await asyncio.to_thread(
  108. Path(".work/knowledge-recovery-acceptance.json").write_text,
  109. json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
  110. )
  111. finally:
  112. app.state.knowledge = original
  113. # 仅删除本次随机生成的独立恢复集合,当前业务集合保持原样。
  114. def remove_target():
  115. for name in (target_name, cli_name, concurrent_name):
  116. with connection(source.model_copy(update={"collection": name})) as (
  117. client, collection
  118. ):
  119. if client.has_collection(collection):
  120. client.drop_collection(collection)
  121. await asyncio.to_thread(remove_target)
  122. await cleanup_vectors(scenario["tenant"])