| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759 |
- """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 _schema_resolver() -> SchemaResolver:
- configured = current_app.extensions.get("data_rule_schema_resolver")
- if configured is not None:
- return configured
- repository = _repository()
- catalog = current_app.extensions.get("data_rule_metadata_catalog")
- return SchemaResolver(catalog or Neo4jSchemaMetadataCatalog(), repository)
- 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("/production-lines/draft-identity")
- def create_production_line_draft_identity():
- """Persist a short-lived, single-use, actor-bound DataFlow reservation."""
- try:
- _closed_body(set())
- result = _repository().reserve_dataflow_draft(
- actor_uid=g.current_user["id"]
- )
- db.session.commit()
- return (
- jsonify(
- success(
- result,
- "生产线草稿身份已创建",
- code=201,
- )
- ),
- 201,
- )
- except (TypeError, ValueError):
- db.session.rollback()
- return _bad_request("生产线草稿身份请求无效")
- except Exception:
- db.session.rollback()
- current_app.logger.exception("create DataFlow draft reservation failed")
- return jsonify(failed("生产线草稿身份暂时不可用", code=503)), 503
- @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,
- )
- candidate = result.get("candidate")
- if (
- body.get("authoring_surface") == "data_standard"
- and (
- not isinstance(candidate, dict)
- or candidate.get("candidate_type") != "rule"
- or not isinstance(candidate.get("rule_spec"), dict)
- )
- ):
- raise ValueError(
- "data_standard authoring must produce a rule candidate"
- )
- 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"],
- }
- 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/<version_id>/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/<version_id>/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/<version_id>/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/<version_id>/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/<asset_type>/<version_id>/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
- def _catalog_schema_context():
- raw_inputs = request.args.get("input_schema_refs")
- output = request.args.get("output_schema_ref")
- if raw_inputs is None and output is None:
- return None, None
- if raw_inputs is None or output is None:
- raise ValueError("catalog schema context is incomplete")
- inputs = json.loads(raw_inputs)
- if not isinstance(inputs, list):
- raise ValueError("catalog input_schema_refs must be an array")
- return inputs, output
- @bp.get("/catalog/assets/<asset_type>/<version_id>")
- def catalog_asset(asset_type: str, version_id: str):
- try:
- if set(request.args) - {
- "input_schema_refs",
- "output_schema_ref",
- }:
- raise ValueError("catalog asset query contains unsupported fields")
- inputs, output = _catalog_schema_context()
- result = _repository().get_published_asset(
- asset_type=asset_type,
- version_id=version_id,
- input_schema_refs=inputs,
- output_schema_ref=output,
- schema_resolver=_schema_resolver() if inputs is not None else None,
- )
- if inputs is not None:
- # SchemaResolver is a read-through snapshot boundary. Persist the
- # IDs returned to this response before making them observable.
- db.session.commit()
- return jsonify(success(result))
- except (TypeError, ValueError, json.JSONDecodeError):
- db.session.rollback()
- return jsonify(failed("已发布资产不存在或上下文无效", code=404)), 404
- except Exception:
- db.session.rollback()
- current_app.logger.exception("load exact catalog asset 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",
- "input_schema_refs",
- "output_schema_ref",
- }
- 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")
- inputs, output = _catalog_schema_context()
- catalog_args = {
- "query": query,
- "asset_type": asset_type,
- "limit": limit,
- "offset": offset,
- }
- if inputs is not None:
- catalog_args.update(
- {
- "input_schema_refs": inputs,
- "output_schema_ref": output,
- "schema_resolver": _schema_resolver(),
- }
- )
- result = _repository().search_published_assets(**catalog_args)
- if inputs is not None:
- db.session.commit()
- return jsonify(success(result))
- except (TypeError, ValueError):
- db.session.rollback()
- return _bad_request("规则目录查询无效")
- except Exception:
- db.session.rollback()
- current_app.logger.exception("load rule catalog failed")
- return jsonify(failed("规则目录暂时不可用", code=503)), 503
- @bp.post("/execution-plans/<plan_id>/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/<plan_id>/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/<plan_id>/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/<version_id>/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/<dataflow_uid>/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
|