This is an automated email from the ASF dual-hosted git repository.

Lee-W pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2ed1196e80e Support require_approval in LLMSchemaCompareOperator 
(#71051)
2ed1196e80e is described below

commit 2ed1196e80e8203389e04be26e9e8f48830eb8ea
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Wed Aug 12 17:18:43 2026 +0800

    Support require_approval in LLMSchemaCompareOperator (#71051)
    
    Co-authored-by: Wei Lee <[email protected]>
---
 .../ai/docs/operators/llm_schema_compare.rst       |  27 +++++
 .../ai/example_dags/example_llm_schema_compare.py  |  21 ++++
 .../common/ai/operators/llm_schema_compare.py      |  45 +++++++-
 .../common/ai/operators/test_llm_schema_compare.py | 123 ++++++++++++++++++++-
 4 files changed, 205 insertions(+), 11 deletions(-)

diff --git a/providers/common/ai/docs/operators/llm_schema_compare.rst 
b/providers/common/ai/docs/operators/llm_schema_compare.rst
index d5f014adf0c..768d96280be 100644
--- a/providers/common/ai/docs/operators/llm_schema_compare.rst
+++ b/providers/common/ai/docs/operators/llm_schema_compare.rst
@@ -114,6 +114,27 @@ The callable may also return a non-empty 
``Sequence[UserContent]`` for
 multimodal inputs -- see
 :ref:`@task.agent multimodal prompts <howto/operator:agent-multimodal>`.
 
+Human-in-the-Loop Approval
+--------------------------
+
+Set ``require_approval=True`` to pause the task after the comparison and wait
+for a human reviewer to approve the result before it is returned. The review
+body shows the compatibility verdict, a mismatch severity summary, and the
+full result JSON. Rejecting the review, or letting ``approval_timeout``
+expire, fails the task:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_schema_compare.py
+    :language: python
+    :start-after: [START howto_operator_llm_schema_compare_approval]
+    :end-before: [END howto_operator_llm_schema_compare_approval]
+
+``require_approval=True`` requires a string prompt: a decorated callable
+returning a ``Sequence[UserContent]`` raises ``TypeError`` before the LLM
+call.
+
+``approval_timeout``, ``allow_modifications``, and the rest of the approval
+behaviour are inherited from :ref:`LLMOperator <howto/operator:llm>`.
+
 Conditional ETL Based on Schema Compatibility
 ----------------------------------------------
 
@@ -166,6 +187,12 @@ Parameters
   catalog-managed sources.
 - ``context_strategy``: To fetch primary keys, foreign keys, and 
indexes.``full`` or ``basic``,
   strongly recommended for cross-system comparisons. default is ``full``
+- ``require_approval``: If ``True``, the task pauses after the comparison and
+  waits for human review before returning the result.  Default ``False``.
+- ``approval_timeout``: Maximum time to wait for a review (``timedelta``).  
``None``
+  means wait indefinitely.  Default ``None``.
+- ``allow_modifications``: If ``True``, the reviewer can edit the result JSON
+  before approving.  Default ``False``.
 
 Logging
 -------
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_schema_compare.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_schema_compare.py
index e884b74a5f8..bf8020e684e 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_schema_compare.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_schema_compare.py
@@ -18,6 +18,8 @@
 
 from __future__ import annotations
 
+from datetime import timedelta
+
 from airflow.providers.common.ai.operators.llm_schema_compare import 
LLMSchemaCompareOperator
 from airflow.providers.common.compat.sdk import dag, task
 from airflow.providers.common.sql.config import DataSourceConfig
@@ -105,6 +107,25 @@ def example_llm_schema_compare_decorator():
 example_llm_schema_compare_decorator()
 
 
+# [START howto_operator_llm_schema_compare_approval]
+@dag(tags=["example"])
+def example_llm_schema_compare_approval():
+    LLMSchemaCompareOperator(
+        task_id="detect_schema_drift_with_approval",
+        prompt="Identify schema mismatches that would break data loading 
between systems",
+        llm_conn_id="pydanticai_default",
+        db_conn_ids=["postgres_source", "snowflake_target"],
+        table_names=["customers"],
+        require_approval=True,
+        approval_timeout=timedelta(hours=1),
+    )
+
+
+# [END howto_operator_llm_schema_compare_approval]
+
+example_llm_schema_compare_approval()
+
+
 # [START howto_operator_llm_schema_compare_conditional]
 @dag(tags=["example"])
 def example_llm_schema_compare_conditional():
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
index d65ca521876..7992419296f 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py
@@ -19,11 +19,12 @@
 from __future__ import annotations
 
 import json
+from collections import Counter
 from collections.abc import Sequence
 from functools import cached_property
 from typing import TYPE_CHECKING, Any, Literal
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, ValidationError
 
 from airflow.providers.common.ai.operators.llm import LLMOperator
 from airflow.providers.common.ai.utils.logging import log_run_summary
@@ -106,6 +107,13 @@ class LLMSchemaCompareOperator(LLMOperator):
     :param context_strategy: ``"basic"`` for column names and types only;
         ``"full"`` to include primary keys, foreign keys, and indexes.
         Default ``"full"``.
+
+    Human-in-the-Loop approval parameters are inherited from
+    :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
+    (``require_approval``, ``approval_timeout``, ``allow_modifications``).
+    The task pauses after the comparison and only returns the result once a
+    reviewer approves. The review body shows the compatibility verdict, a
+    mismatch severity summary, and the full result JSON.
     """
 
     template_fields: Sequence[str] = (
@@ -126,9 +134,9 @@ class LLMSchemaCompareOperator(LLMOperator):
         system_prompt: str = DEFAULT_SYSTEM_PROMPT,
         **kwargs: Any,
     ) -> None:
-        kwargs.pop("output_type", None)
-        if kwargs.get("require_approval"):
-            raise ValueError("require_approval=True is not supported by 
LLMSchemaCompareOperator.")
+        kwargs["output_type"] = SchemaCompareResult
+        # execute() always returns a dict, so the approval resume path must too
+        kwargs["serialize_output"] = True
         super().__init__(**kwargs)
         self.data_sources = data_sources or []
         self.db_conn_ids = db_conn_ids or []
@@ -299,6 +307,9 @@ class LLMSchemaCompareOperator(LLMOperator):
         return "".join(parts)
 
     def execute(self, context: Context) -> dict[str, Any]:
+        if self.require_approval:
+            self.validate_approval_prompt()  # type: ignore[misc]
+
         schema_context = self._build_schema_context()
 
         self.log.info("Schema comparison context:\n%s", schema_context)
@@ -313,8 +324,32 @@ class LLMSchemaCompareOperator(LLMOperator):
         self.log.info("Running LLM schema comparison...")
         result = agent.run_sync(self.prompt, usage_limits=self.usage_limits)
         log_run_summary(self.log, result)
+        output = result.output
 
-        output_result = result.output.model_dump()
+        output_result = output.model_dump()
         self.log.info("Schema comparison result: \n %s", 
json.dumps(output_result, indent=2))
 
+        if self.require_approval:
+            severity_counts = Counter(mismatch.severity for mismatch in 
output.mismatches)
+            summary = ", ".join(
+                f"{severity_counts[severity]} {severity}"
+                for severity in ("critical", "warning", "info")
+                if severity_counts[severity]
+            )
+            body = (
+                f"Compatible: {output.compatible}"
+                + (f" (mismatches: {summary})" if summary else "")
+                + f"\n\n```\nPrompt: 
{self.prompt}\n\n{output.model_dump_json(indent=2)}\n```"
+            )
+            self.defer_for_approval(context, output, body=body)  # type: 
ignore[misc]
+
         return output_result
+
+    def execute_complete(self, context: Context, generated_output: str, event: 
dict[str, Any]) -> Any:
+        output = super().execute_complete(context, generated_output, event)
+        if isinstance(output, dict):
+            return output
+        try:
+            return SchemaCompareResult.model_validate_json(output).model_dump()
+        except ValidationError as e:
+            raise ValueError(f"Reviewed output is not a valid 
SchemaCompareResult: {e}") from e
diff --git 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
index 01fee1950d1..656457de295 100644
--- 
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
+++ 
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py
@@ -16,20 +16,36 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from unittest import mock
 from unittest.mock import MagicMock
+from uuid import uuid4
 
 import pytest
 
 from airflow.providers.common.ai.operators.llm_schema_compare import (
     LLMSchemaCompareOperator,
     SchemaCompareResult,
+    SchemaMismatch,
 )
-from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
+from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException, TaskDeferred
 from airflow.providers.common.sql.config import DataSourceConfig
 from airflow.providers.common.sql.datafusion.engine import DataFusionEngine
 from airflow.providers.common.sql.hooks.sql import DbApiHook
 
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS, 
AIRFLOW_V_3_3_PLUS
+
+if AIRFLOW_V_3_3_PLUS:
+    from airflow.sdk.exceptions import TaskAwaitingInput
+else:
+    TaskAwaitingInput = TaskDeferred  # type: ignore[assignment, misc]
+
+
+def _make_context():
+    ti = MagicMock()
+    ti.id = uuid4()
+    return MagicMock(**{"__getitem__": lambda self, key: {"task_instance": 
ti}[key]})
+
 
 def _make_mock_run_result(output):
     """Create a mock AgentRunResult compatible with log_run_summary."""
@@ -97,11 +113,6 @@ class TestLLMSchemaCompareOperator:
                 "at-least two combinations",
                 id="one_datasource_only",
             ),
-            pytest.param(
-                {"require_approval": True},
-                "require_approval=True is not supported",
-                id="require_approval",
-            ),
         ],
     )
     def test_init_validation(self, kwargs, expected_error):
@@ -558,3 +569,103 @@ class TestLLMSchemaCompareOperator:
 
         with pytest.raises(AirflowOptionalProviderFeatureException):
             op._introspect_schema_from_datafusion(ds)
+
+
[email protected](
+    not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with 
Airflow >= 3.1.0"
+)
+class TestLLMSchemaCompareOperatorApproval:
+    """Tests for LLMSchemaCompareOperator with require_approval=True."""
+
+    _APPROVAL_KWARGS = dict(
+        db_conn_ids=["postgres_default", "snowflake_default"],
+        table_names=["orders"],
+        require_approval=True,
+    )
+
+    def test_require_approval_accepted(self):
+        op = LLMSchemaCompareOperator(**_BASE_KWARGS, **self._APPROVAL_KWARGS)
+        assert op.require_approval is True
+        assert op.output_type is SchemaCompareResult
+
+    @mock.patch(
+        
"airflow.providers.common.ai.operators.llm_schema_compare.LLMSchemaCompareOperator._build_schema_context"
+    )
+    @mock.patch(
+        
"airflow.providers.common.ai.operators.llm_schema_compare.LLMSchemaCompareOperator._build_system_prompt"
+    )
+    @mock.patch("airflow.providers.standard.triggers.hitl.HITLTrigger", 
autospec=True)
+    @mock.patch("airflow.sdk.execution_time.hitl.upsert_hitl_detail")
+    def test_execute_with_approval_pauses_with_summary_body(
+        self, mock_upsert, mock_trigger_cls, mock_build_system_prompt, 
mock_build_schema_context, caplog
+    ):
+        mock_build_schema_context.return_value = "schema_context"
+        mock_build_system_prompt.return_value = "system_prompt"
+        result = SchemaCompareResult(
+            compatible=False,
+            mismatches=[
+                SchemaMismatch(
+                    source="pg.orders",
+                    target="sf.orders",
+                    column="total",
+                    source_type="numeric(10,2)",
+                    target_type="NUMBER(5,0)",
+                    severity="critical",
+                    description="Precision loss",
+                    suggested_action="Widen target column",
+                    migration_query="ALTER TABLE orders ...",
+                )
+            ],
+            summary="One critical mismatch",
+        )
+
+        op = LLMSchemaCompareOperator(**_BASE_KWARGS, **self._APPROVAL_KWARGS)
+        mock_agent = mock.Mock()
+        mock_agent.run_sync.return_value = _make_mock_run_result(result)
+        op.llm_hook = 
mock.Mock(create_agent=mock.Mock(return_value=mock_agent))
+
+        with pytest.raises(TaskAwaitingInput) as exc_info:
+            op.execute(context=_make_context())
+
+        assert exc_info.value.kwargs["generated_output"] == 
result.model_dump_json()
+        body = mock_upsert.call_args.kwargs["body"]
+        assert body.startswith("Compatible: False (mismatches: 1 critical)")
+        assert "Precision loss" in body
+        assert f"Schema comparison result: \n {json.dumps(result.model_dump(), 
indent=2)}" in caplog
+
+    def test_execute_complete_approved_returns_dict(self):
+        result = SchemaCompareResult(compatible=True, mismatches=[], 
summary="All good")
+        op = LLMSchemaCompareOperator(**_BASE_KWARGS, **self._APPROVAL_KWARGS)
+        event = {"chosen_options": ["Approve"], "responded_by_user": "admin"}
+
+        resumed = op.execute_complete({}, 
generated_output=result.model_dump_json(), event=event)
+
+        assert resumed == result.model_dump()
+
+    @pytest.mark.parametrize(
+        "modified",
+        ['{"compatible": true, "mismatches": []}', "looks fine to me"],
+        ids=["missing-summary", "not-json"],
+    )
+    def test_execute_complete_rejects_invalid_modified_output(self, modified):
+        result = SchemaCompareResult(compatible=True, mismatches=[], 
summary="All good")
+        op = LLMSchemaCompareOperator(**_BASE_KWARGS, **self._APPROVAL_KWARGS, 
allow_modifications=True)
+        event = {
+            "chosen_options": ["Approve"],
+            "responded_by_user": "admin",
+            "params_input": {"output": modified},
+        }
+
+        with pytest.raises(ValueError, match="not a valid 
SchemaCompareResult"):
+            op.execute_complete({}, generated_output=result.model_dump_json(), 
event=event)
+
+    def test_execute_rejects_sequence_prompt_with_require_approval(self):
+        op = LLMSchemaCompareOperator(
+            task_id="test_task",
+            prompt=["describe", b"bytes"],  # type: ignore[arg-type]
+            llm_conn_id="llm_conn",
+            **self._APPROVAL_KWARGS,
+        )
+
+        with pytest.raises(TypeError, match="require_approval=True"):
+            op.execute(context=_make_context())

Reply via email to