"""Governed control-plane endpoints for AI-authored data rules.""" from __future__ import annotations import hashlib import json from datetime import UTC, datetime, timedelta from typing import Any from flask import current_app, g, jsonify, request from app import db from app.api.data_rules import bp from app.core.data_rules.authoring import ( OpenAICompatibleRuleModel, RuleAuthoringAgent, ) from app.core.data_rules.contracts import ( dataflow_spec_hash, rule_spec_hash, standard_spec_hash, validate_dataflow_spec, validate_rule_spec, validate_standard_spec, ) from app.core.data_rules.production_line import resolve_production_line from app.core.data_rules.publication import ( GenerationReceiptSigner, LogicalRuleCompiler, PhysicalPlanPublicationService, RulePublicationService, RuleValidationRejected, ServerOwnedLogicalDryRunRunner, ServerOwnedPhysicalPreflightRunner, generation_receipt_claims, ) from app.core.data_rules.release import ProductionLineReleaseService from app.core.data_rules.repository import DataRuleRepository from app.core.data_rules.schema_resolver import ( Neo4jSchemaMetadataCatalog, SchemaResolver, ) from app.models.result import failed, success _VALIDATORS = { "rule": (validate_rule_spec, rule_spec_hash), "standard": (validate_standard_spec, standard_spec_hash), "dataflow": (validate_dataflow_spec, dataflow_spec_hash), } def _body() -> dict[str, Any]: value = request.get_json(silent=True) if not isinstance(value, dict): raise ValueError("request body must be an object") return value def _bad_request(message: str = "规则请求无效"): return jsonify(failed(message, code=400)), 400 def _closed_body(allowed: set[str]) -> dict[str, Any]: body = _body() if set(body) - allowed: raise ValueError("request contains unsupported fields") return body def _repository() -> DataRuleRepository: configured = current_app.extensions.get("data_rule_repository") if configured is not None: return configured return DataRuleRepository(db.session) def _release_service() -> ProductionLineReleaseService: configured = current_app.extensions.get("production_line_release_service") if configured is not None: return configured repository = _repository() resolver = current_app.extensions.get("data_rule_schema_resolver") if resolver is None: catalog = current_app.extensions.get("data_rule_metadata_catalog") resolver = SchemaResolver( catalog or Neo4jSchemaMetadataCatalog(), repository ) return ProductionLineReleaseService(repository, schema_resolver=resolver) def _receipt_signer() -> GenerationReceiptSigner: configured = current_app.extensions.get("generation_receipt_signer") if configured is not None: return configured secret = current_app.config.get("RULE_GENERATION_RECEIPT_SECRET") if not isinstance(secret, str) or not secret.strip(): raise RuntimeError( "dedicated generation receipt secret is not configured" ) signer = GenerationReceiptSigner(secret) current_app.extensions["generation_receipt_signer"] = signer return signer def _rule_artifact_store(): configured = current_app.extensions.get("rule_artifact_store") if configured is not None: return configured from minio import Minio from app.runner.artifacts import ArtifactStore store = ArtifactStore( Minio( current_app.config["MINIO_HOST"], access_key=current_app.config["MINIO_USER"], secret_key=current_app.config["MINIO_PASSWORD"], secure=bool(current_app.config["MINIO_SECURE"]), ), bucket=current_app.config["MINIO_BUCKET"], max_artifact_bytes=32 * 1024 * 1024, max_rows=100_000, memory_limit_bytes=256 * 1024 * 1024, max_ttl_seconds=3600, ) current_app.extensions["rule_artifact_store"] = store return store def _publication_service() -> RulePublicationService: configured = current_app.extensions.get("rule_publication_service") if configured is not None: return configured test_runner = current_app.extensions.get("rule_validation_test_runner") if test_runner is None: try: test_runner = ServerOwnedLogicalDryRunRunner( _rule_artifact_store() ) except Exception as exc: raise RuntimeError( "trusted rule validation test runner is not configured" ) from exc current_app.extensions["rule_validation_test_runner"] = test_runner service = RulePublicationService( _repository(), receipt_signer=_receipt_signer(), compiler=LogicalRuleCompiler(), test_runner=test_runner, ) current_app.extensions["rule_publication_service"] = service return service def _physical_publication_service() -> PhysicalPlanPublicationService: configured = current_app.extensions.get( "physical_plan_publication_service" ) if configured is not None: return configured test_runner = current_app.extensions.get("rule_physical_test_runner") if test_runner is None: try: from app.core.data_source.runtime import get_data_source_manager test_runner = ServerOwnedPhysicalPreflightRunner( _rule_artifact_store(), datasource_manager=get_data_source_manager(), ) except Exception as exc: raise RuntimeError( "trusted physical plan test runner is not configured" ) from exc current_app.extensions["rule_physical_test_runner"] = test_runner service = PhysicalPlanPublicationService( _repository(), test_runner=test_runner ) current_app.extensions["physical_plan_publication_service"] = service return service def _metadata_hash(value: Any) -> str: return hashlib.sha256( json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") ).hexdigest() @bp.get("/capabilities") def capabilities(): return jsonify( success( { "natural_language_authoring": True, "schema_constrained_candidates": True, "production_line_preview": True, "immutable_asset_versions": True, "server_side_publishing": True, "production_line_release": True, "data_factory_activation": False, } ) ) @bp.post("/validate") def validate_asset(): try: body = _closed_body({"asset_type", "spec"}) asset_type = body.get("asset_type") if asset_type not in _VALIDATORS: raise ValueError("unsupported asset type") validator, hasher = _VALIDATORS[asset_type] normalized = validator(body.get("spec")) return jsonify( success( { "asset_type": asset_type, "normalized": normalized, "spec_hash": hasher(normalized), } ) ) except (TypeError, ValueError): return _bad_request("规则定义无效") def _authoring_agent() -> RuleAuthoringAgent: configured = current_app.extensions.get("data_rule_authoring_agent") if configured is not None: return configured api_key = current_app.config.get("LLM_API_KEY") or current_app.config.get( "DEEPSEEK_API_KEY" ) if not api_key: raise RuntimeError("rule authoring model is not configured") agent = RuleAuthoringAgent(model=OpenAICompatibleRuleModel()) current_app.extensions["data_rule_authoring_agent"] = agent return agent @bp.post("/interpret") def interpret_rule(): try: body = _closed_body({"source_text", "authoring_surface", "context"}) receipt_signer = _receipt_signer() repository = _repository() validation_context = repository.resolve_validation_context( body.get("context", {}) ) result = _authoring_agent().interpret( source_text=body.get("source_text"), authoring_surface=body.get("authoring_surface"), context=validation_context, ) audit = repository.record_generation_run( evidence=result, created_by=g.current_user["id"], validation_context=validation_context, ) result = { **result, "generation_run_id": audit["id"], "correlation_id": audit["correlation_id"], } candidate = result.get("candidate") if ( result.get("status") == "ready" and isinstance(candidate, dict) and candidate.get("candidate_type") == "rule" and isinstance(candidate.get("rule_spec"), dict) ): claims = generation_receipt_claims( generation_run_id=audit["id"], actor_uid=g.current_user["id"], source_text=result["source_text"], candidate_hash=result["candidate_hash"], rule_spec=candidate["rule_spec"], model_hash=result.get("model_hash") or _metadata_hash( { "provider": result.get("model_provider", "unknown"), "name": result.get("model_name", "unknown"), } ), prompt_hash=result.get("prompt_hash") or _metadata_hash(result.get("prompt_version", "unknown")), context_hash=result["context_hash"], expires_at=datetime.now(UTC) + timedelta(minutes=10), ) result["generation_receipt"] = receipt_signer.issue(claims) db.session.commit() return jsonify(success(result)) except (TypeError, ValueError): db.session.rollback() return _bad_request("自然语言规则描述无效") except RuntimeError: db.session.rollback() return jsonify(failed("AI 规则解析服务未配置", code=503)), 503 except Exception: db.session.rollback() current_app.logger.exception("AI rule interpretation failed") return jsonify(failed("AI 规则解析暂时不可用", code=503)), 503 @bp.post("/rule-versions") def create_rule_version(): try: body = _closed_body( { "rule_spec", "source_text", "generation_receipt", "category", "source_language", "generated_kind", } ) # Enforce V2 at the HTTP boundary even when a test/different # repository implementation is injected. rule_spec = validate_rule_spec(body.get("rule_spec")) result = _publication_service().create_draft( rule_spec=rule_spec, source_text=body.get("source_text"), category=body.get("category", "general"), source_language=body.get("source_language", "zh-CN"), generated_kind=body.get("generated_kind", "rulespec"), actor_uid=g.current_user["id"], generation_receipt=body.get("generation_receipt"), ) db.session.commit() return jsonify(success(result, "规则版本创建成功")), 201 except (TypeError, ValueError): db.session.rollback() return _bad_request("规则版本定义无效") except Exception: db.session.rollback() current_app.logger.exception("create rule version failed") return jsonify(failed("规则版本创建失败", code=500)), 500 @bp.post("/rule-versions//publish") def publish_rule_version(version_id: str): try: if request.get_data(cache=True) and request.get_json( silent=True ) not in ( None, {}, ): raise ValueError("publish request must not contain fields") result = _publication_service().publish( version_id, g.current_user["id"] ) db.session.commit() return jsonify(success(result, "规则版本发布成功")) except (TypeError, ValueError): db.session.rollback() return jsonify(failed("规则版本无法发布", code=409)), 409 except Exception: db.session.rollback() current_app.logger.exception("publish rule version failed") return jsonify(failed("规则版本发布失败", code=500)), 500 @bp.post("/rule-versions//validate") def validate_rule_version(version_id: str): try: if request.get_data(cache=True) and request.get_json( silent=True ) not in ( None, {}, ): raise ValueError("validate request must not contain fields") result = _publication_service().validate( version_id, g.current_user["id"] ) db.session.commit() return jsonify(success(result, "规则版本编译验证成功")) except RuleValidationRejected: db.session.commit() return jsonify(failed("规则版本编译验证失败", code=409)), 409 except (TypeError, ValueError): db.session.rollback() return jsonify(failed("规则版本编译验证失败", code=409)), 409 except RuntimeError: db.session.rollback() return jsonify(failed("规则验证服务未配置", code=503)), 503 @bp.post("/rule-versions//test") def test_rule_version(version_id: str): try: body = _closed_body({"plan_id"}) result = _publication_service().test( version_id, g.current_user["id"], plan_id=body.get("plan_id"), ) db.session.commit() return jsonify(success(result, "规则版本样本测试成功")) except (TypeError, ValueError): db.session.rollback() return jsonify(failed("规则版本样本测试失败", code=409)), 409 except RuntimeError: db.session.rollback() return jsonify(failed("规则测试服务未配置", code=503)), 503 @bp.get("/rule-versions//evidence") def rule_version_evidence(version_id: str): try: return jsonify( success( _repository().get_asset_evidence( asset_type="rule", version_id=version_id ) ) ) except (TypeError, ValueError): return jsonify(failed("规则证据不存在", code=404)), 404 except Exception: current_app.logger.exception("load rule evidence failed") return jsonify(failed("规则证据暂时不可用", code=503)), 503 @bp.get("/catalog/assets///evidence") def catalog_asset_evidence(asset_type: str, version_id: str): try: return jsonify( success( _repository().get_asset_evidence( asset_type=asset_type, version_id=version_id, ) ) ) except (TypeError, ValueError): return jsonify(failed("资产证据不存在", code=404)), 404 except Exception: current_app.logger.exception("load catalog asset evidence failed") return jsonify(failed("资产证据暂时不可用", code=503)), 503 @bp.get("/catalog") @bp.get("/catalog/rule-versions") def published_rule_catalog(): try: legacy_rule_alias = request.path.endswith("/rule-versions") allowed = {"query", "limit", "offset"} if not legacy_rule_alias: allowed.add("asset_type") if set(request.args) - allowed: raise ValueError("catalog query contains unsupported fields") query = request.args.get("query", "") limit = int(request.args.get("limit", "50")) offset = int(request.args.get("offset", "0")) asset_type = ( "rule" if legacy_rule_alias else request.args.get("asset_type") or None ) if asset_type not in {None, "rule", "standard"}: raise ValueError("catalog asset_type is invalid") if len(query) > 200 or limit < 1 or limit > 100: raise ValueError("catalog bounds are invalid") if offset < 0 or offset > 1_000_000: raise ValueError("catalog offset is invalid") return jsonify( success( _repository().search_published_assets( query=query, asset_type=asset_type, limit=limit, offset=offset, ) ) ) except (TypeError, ValueError): return _bad_request("规则目录查询无效") except Exception: current_app.logger.exception("load rule catalog failed") return jsonify(failed("规则目录暂时不可用", code=503)), 503 @bp.post("/execution-plans//validate") def validate_physical_plan(plan_id: str): try: if request.get_data(cache=True) and request.get_json( silent=True ) not in ( None, {}, ): raise ValueError("validate request must not contain fields") result = _physical_publication_service().validate( plan_id, g.current_user["id"] ) db.session.commit() return jsonify(success(result, "物理执行计划编译证据已确认")) except (TypeError, ValueError): db.session.rollback() return jsonify(failed("物理执行计划验证失败", code=409)), 409 except RuntimeError: db.session.rollback() return jsonify(failed("物理计划验证服务未配置", code=503)), 503 @bp.post("/execution-plans//test") def test_physical_plan(plan_id: str): try: if request.get_data(cache=True) and request.get_json( silent=True ) not in ( None, {}, ): raise ValueError("test request must not contain fields") result = _physical_publication_service().test( plan_id, g.current_user["id"] ) db.session.commit() return jsonify(success(result, "物理执行计划样本测试成功")) except (TypeError, ValueError): db.session.rollback() return jsonify(failed("物理执行计划样本测试失败", code=409)), 409 except RuntimeError: db.session.rollback() return jsonify(failed("物理计划测试服务未配置", code=503)), 503 @bp.post("/execution-plans//publish") def publish_physical_plan(plan_id: str): try: if request.get_data(cache=True) and request.get_json( silent=True ) not in ( None, {}, ): raise ValueError("publish request must not contain fields") result = _physical_publication_service().publish( plan_id, g.current_user["id"] ) db.session.commit() return jsonify(success(result, "物理执行计划发布成功")) except (TypeError, ValueError): db.session.rollback() return jsonify(failed("物理执行计划无法发布", code=409)), 409 except RuntimeError: db.session.rollback() return jsonify(failed("物理计划发布服务未配置", code=503)), 503 @bp.post("/standard-versions") def create_standard_version(): try: body = _closed_body({"standard_spec", "source_text"}) result = _repository().create_standard_version( standard_spec=body.get("standard_spec"), source_text=body.get("source_text"), created_by=g.current_user["id"], ) db.session.commit() return jsonify(success(result, "数据标准版本创建成功")), 201 except (TypeError, ValueError): db.session.rollback() return _bad_request("数据标准版本定义无效") except Exception: db.session.rollback() current_app.logger.exception("create standard version failed") return jsonify(failed("数据标准版本创建失败", code=500)), 500 @bp.post("/standard-versions//publish") def publish_standard_version(version_id: str): try: if request.get_data(cache=True) and request.get_json( silent=True ) not in ( None, {}, ): raise ValueError("publish request must not contain fields") result = _repository().publish_standard_version( version_id=version_id, published_by=g.current_user["id"], ) db.session.commit() return jsonify(success(result, "数据标准版本发布成功")) except (TypeError, ValueError): db.session.rollback() return jsonify(failed("数据标准版本无法发布", code=409)), 409 except Exception: db.session.rollback() current_app.logger.exception("publish standard version failed") return jsonify(failed("数据标准版本发布失败", code=500)), 500 @bp.post("/production-lines/resolve") def resolve_production_line_preview(): try: body = _body() package = resolve_production_line( body.get("dataflow_spec"), body.get("standard_versions"), body.get("rule_versions"), component_binding_ids=body.get("component_binding_ids"), ) return jsonify( success( { "preview": True, "release_ready": False, "package": package, } ) ) except (TypeError, ValueError): return _bad_request("数据生产线定义无效") @bp.post("/production-lines//release") def release_production_line(dataflow_uid: str): try: body = _closed_body( { "dataflow_spec", "source_text", } ) result = _release_service().release( dataflow_uid=dataflow_uid, dataflow_spec=body.get("dataflow_spec"), source_text=body.get("source_text"), created_by=g.current_user["id"], ) db.session.commit() return jsonify(success(result, "数据生产线发布成功")), 201 except (TypeError, ValueError): db.session.rollback() return jsonify(failed("数据生产线无法发布", code=409)), 409 except Exception: db.session.rollback() current_app.logger.exception("release production line failed") return jsonify(failed("数据生产线发布失败", code=500)), 500