test_rule_publication_lifecycle.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import os
  5. from datetime import UTC, datetime, timedelta
  6. import polars as pl
  7. import pytest
  8. from minio import Minio
  9. from sqlalchemy import create_engine, text
  10. from sqlalchemy.orm import Session
  11. from app.core.common.identifiers import new_governance_uid
  12. from app.core.data_rules.contracts import rule_spec_hash
  13. from app.core.data_rules.execution_contracts import canonical_schema_hash
  14. from app.core.data_rules.publication import (
  15. GenerationReceiptSigner,
  16. LogicalRuleCompiler,
  17. RulePublicationService,
  18. ServerOwnedLogicalDryRunRunner,
  19. ServerOwnedPhysicalPreflightRunner,
  20. generation_receipt_claims,
  21. )
  22. from app.core.data_rules.repository import DataRuleRepository
  23. from app.runner.artifacts import ArtifactStore
  24. from tests.core.data_rules.test_contracts import valid_rule_spec
  25. from tests.integration.test_data_rule_polars_execution import _compose_value
  26. pytestmark = pytest.mark.integration
  27. def _hash(value):
  28. return hashlib.sha256(
  29. json.dumps(
  30. value,
  31. sort_keys=True,
  32. separators=(",", ":"),
  33. ensure_ascii=False,
  34. ).encode("utf-8")
  35. ).hexdigest()
  36. @pytest.fixture()
  37. def database_url():
  38. value = os.environ.get("TEST_DATABASE_URL")
  39. if not value:
  40. pytest.skip("TEST_DATABASE_URL is not configured")
  41. return value
  42. def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
  43. minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
  44. minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
  45. minio_port = _compose_value(r'"(19000):9000"')
  46. bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
  47. store = ArtifactStore(
  48. Minio(
  49. f"127.0.0.1:{minio_port}",
  50. access_key=minio_user,
  51. secret_key=minio_password,
  52. secure=False,
  53. ),
  54. bucket=bucket,
  55. max_artifact_bytes=32 * 1024 * 1024,
  56. max_rows=100_000,
  57. memory_limit_bytes=256 * 1024 * 1024,
  58. max_ttl_seconds=3600,
  59. )
  60. fields = [
  61. {"name": "name", "type": "string", "nullable": True},
  62. {"name": "mobile", "type": "string", "nullable": True},
  63. ]
  64. sample = store.write(
  65. pl.DataFrame(
  66. {
  67. "name": [" Alice ", " Bob ", " Carol "],
  68. "mobile": ["13800138000", "invalid", "13900139000"],
  69. }
  70. ),
  71. new_governance_uid(),
  72. 600,
  73. schema_fields=fields,
  74. )
  75. engine = create_engine(database_url)
  76. with engine.connect() as connection:
  77. transaction = connection.begin()
  78. try:
  79. actor = connection.execute(
  80. text(
  81. "SELECT id::text FROM public.users "
  82. "WHERE status = 'active' ORDER BY created_at LIMIT 1"
  83. )
  84. ).scalar_one()
  85. session = Session(bind=connection)
  86. repository = DataRuleRepository(session)
  87. snapshot_value = {
  88. "schema_ref": "bd:rule-publication:integration",
  89. "source_revision": "integration:1",
  90. "fields": fields,
  91. }
  92. snapshot_value["schema_hash"] = canonical_schema_hash(
  93. snapshot_value["fields"]
  94. )
  95. snapshot = repository.persist_schema_snapshot(
  96. snapshot=snapshot_value
  97. )
  98. validation_context = {
  99. "schema_snapshot_id": snapshot["id"],
  100. "schema_hash": snapshot["schema_hash"],
  101. "fields": snapshot["fields"],
  102. "sample_artifact_ref": sample["artifact_ref"],
  103. "sample_artifact_digest": sample["digest"],
  104. }
  105. spec = valid_rule_spec()
  106. spec["input_schema_ref"] = snapshot["schema_ref"]
  107. spec["output_schema_ref"] = snapshot["schema_ref"]
  108. spec["steps"][1]["on_failure"] = "quarantine"
  109. candidate = {
  110. "schema_version": "1.0",
  111. "candidate_type": "rule",
  112. "rule_spec": spec,
  113. "standard_spec": None,
  114. "assumptions": [],
  115. "ambiguities": [],
  116. "confidence": 0.99,
  117. "explanation": "integration candidate",
  118. }
  119. evidence = {
  120. "status": "ready",
  121. "source_text": "手机号去空格后必须为11位数字",
  122. "authoring_surface": "data_standard",
  123. "candidate": candidate,
  124. "model_provider": "integration",
  125. "model_name": "closed-fixture",
  126. "prompt_version": "integration-v1",
  127. "schema_version": "1.0",
  128. "context_hash": _hash(validation_context),
  129. "candidate_hash": repository.candidate_hash(candidate),
  130. "model_hash": "a" * 64,
  131. "prompt_hash": "b" * 64,
  132. "repair_attempts": 0,
  133. "generation_attempts": [],
  134. }
  135. generation = repository.record_generation_run(
  136. evidence=evidence,
  137. created_by=actor,
  138. validation_context=validation_context,
  139. )
  140. signer = GenerationReceiptSigner(
  141. "integration-receipt-secret-with-entropy"
  142. )
  143. claims = generation_receipt_claims(
  144. generation_run_id=generation["id"],
  145. actor_uid=actor,
  146. source_text=evidence["source_text"],
  147. candidate_hash=evidence["candidate_hash"],
  148. rule_spec=spec,
  149. model_hash=evidence["model_hash"],
  150. prompt_hash=evidence["prompt_hash"],
  151. context_hash=evidence["context_hash"],
  152. expires_at=datetime.now(UTC) + timedelta(minutes=5),
  153. )
  154. receipt = signer.issue(claims)
  155. service = RulePublicationService(
  156. repository,
  157. receipt_signer=signer,
  158. compiler=LogicalRuleCompiler(),
  159. test_runner=ServerOwnedLogicalDryRunRunner(store),
  160. )
  161. draft = service.create_draft(
  162. rule_spec=spec,
  163. source_text=evidence["source_text"],
  164. actor_uid=actor,
  165. generation_receipt=receipt,
  166. category="standard_clause",
  167. source_language="zh-CN",
  168. generated_kind="rulespec",
  169. )
  170. assert draft["status"] == "draft"
  171. assert draft["spec_hash"] == rule_spec_hash(spec)
  172. with pytest.raises(ValueError, match="consumable"):
  173. service.create_draft(
  174. rule_spec=spec,
  175. source_text=evidence["source_text"],
  176. actor_uid=actor,
  177. generation_receipt=receipt,
  178. category="standard_clause",
  179. source_language="zh-CN",
  180. generated_kind="rulespec",
  181. )
  182. compiled = service.validate(draft["id"], actor)
  183. assert compiled["plan_status"] == "compiled"
  184. logical_plan = (
  185. connection.execute(
  186. text(
  187. "SELECT plan, plan_hash, schema_hashes "
  188. "FROM public.rule_logical_plans "
  189. "WHERE id = CAST(:id AS uuid)"
  190. ),
  191. {"id": compiled["plan_id"]},
  192. )
  193. .mappings()
  194. .one()
  195. )
  196. physical_result = ServerOwnedPhysicalPreflightRunner(store).run(
  197. {
  198. "backend": "polars_batch",
  199. "plan": logical_plan["plan"],
  200. "plan_hash": logical_plan["plan_hash"],
  201. "schema_hashes": logical_plan["schema_hashes"],
  202. "binding_hashes": {},
  203. "sample_artifact": {
  204. "artifact_ref": sample["artifact_ref"],
  205. "digest": sample["digest"],
  206. "schema_fields": fields,
  207. },
  208. }
  209. )
  210. assert physical_result["counts"]["rows_quarantined"] == 1
  211. tampered_sample = repository.load_logical_test_context(
  212. version_id=draft["id"],
  213. plan_id=compiled["plan_id"],
  214. )
  215. tampered_sample["sample_artifact_digest"] = "f" * 64
  216. with pytest.raises(ValueError, match="drifted"):
  217. ServerOwnedLogicalDryRunRunner(store).run(tampered_sample)
  218. tested = service.test(
  219. draft["id"], actor, plan_id=compiled["plan_id"]
  220. )
  221. assert tested["version_status"] == "validated"
  222. assert tested["test_evidence"]["counts"]["rows_quarantined"] == 1
  223. assert tested["test_evidence"]["counts"]["rows_rejected"] == 0
  224. published = service.publish(draft["id"], actor)
  225. assert published["status"] == "published"
  226. assert published["plan_status"] == "published"
  227. assert service.publish(draft["id"], actor) == published
  228. assert service.catalog(query=spec["name"], limit=10)[0][
  229. "id"
  230. ] == draft["id"]
  231. rows = connection.execute(
  232. text(
  233. "SELECT rv.status, lp.status, "
  234. "(SELECT COUNT(*) FROM public.rule_logical_compile_evidence "
  235. "WHERE logical_plan_id = lp.id) AS compile_count, "
  236. "(SELECT COUNT(*) FROM public.rule_logical_test_evidence "
  237. "WHERE logical_plan_id = lp.id) AS test_count "
  238. "FROM public.data_rule_versions rv "
  239. "JOIN public.rule_logical_plans lp "
  240. "ON lp.rule_version_id = rv.id "
  241. "WHERE rv.id = CAST(:id AS uuid)"
  242. ),
  243. {"id": draft["id"]},
  244. ).one()
  245. assert tuple(rows) == ("published", "published", 1, 1)
  246. finally:
  247. transaction.rollback()
  248. engine.dispose()
  249. store.delete(sample["artifact_ref"])