kaxil commented on code in PR #74493:
URL: https://github.com/apache/airflow/pull/74493#discussion_r4394101963


##########
providers/common/ai/tests/unit/common/ai/operators/test_llm.py:
##########
@@ -1545,6 +1545,44 @@ def 
test_approval_resume_survives_unparsable_usage_limits(self, mock_upsert):
         assert resumed == "draft"
         accessor.delete.assert_called_once_with(USAGE_BUDGET_KEY)
 
+    @patch.object(LLMOperator, "skip")
+    def 
test_execute_complete_reject_skips_downstream_without_fail_on_reject(self, 
mock_skip):
+        op = LLMOperator(
+            task_id="t", prompt="p", llm_conn_id="c", require_approval=True, 
fail_on_reject=False
+        )
+        downstream = MagicMock(is_teardown=False)
+        task = MagicMock()
+        task.get_direct_relatives.return_value = [downstream, 
MagicMock(is_teardown=True)]
+        ti = MagicMock()
+        ctx = MagicMock(**{"__getitem__": lambda self, key: {"task": task, 
"ti": ti}[key]})
+
+        result = op.execute_complete(
+            ctx,
+            generated_output="output",
+            event={"chosen_options": ["Reject"], "responded_by_user": {"id": 
"u1", "name": "admin"}},
+        )
+
+        assert result is None
+        assert mock_skip.call_args.kwargs["ti"] is ti
+        assert list(mock_skip.call_args.kwargs["tasks"]) == [downstream]
+
+    @pytest.mark.parametrize(
+        ("require_approval", "fail_on_reject", "expected"),
+        [(True, False, True), (True, True, False), (False, False, False)],

Review Comment:
   In all three cases `require_approval` equals `_may_review`, so this still 
passes if the check in `__init__` goes back to `self.require_approval`. Could 
we add a case with `require_approval=False` and a reviewing `decision_policy` 
(one with `min_confidence` set), once with `fail_on_reject=False` (expect 
`True`) and once with `fail_on_reject=True` (expect `False`)?



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm.py:
##########
@@ -575,6 +600,19 @@ def _resolve_task_instance(self, context: Context) -> Any:
             return None
         return ti if callable(getattr(ti, "xcom_push", None)) else None
 
+    def _skip_downstream_on_reject(self, context: Context, event: dict[str, 
Any]) -> None:
+        self.log.info("Rejected by %s. Skipping downstream tasks...", 
self._describe_responder(event))

Review Comment:
   On a timeout rejection this logs "Rejected by the approval timeout default", 
which doesn't say which setting or deadline did it. The `HITLRejectException` 
caught in `_complete_review` already says `approval_timeout expired with 
on_approval_timeout='reject'`. Could we pass it in and log its message, so the 
task log shows nobody clicked Reject? With this PR the line is reachable from 
four more operators.



##########
providers/common/ai/docs/approval_gates.rst:
##########
@@ -191,9 +191,8 @@ Timeouts
 ``[scheduler] trigger_timeout_check_interval`` seconds (15 by default), so a 
review can
 stay open that long past its deadline. On Airflow 3.1 and 3.2 the triggerer 
enforces the
 deadline. ``on_approval_timeout="approve"`` returns the generated output when 
the
-timeout expires. ``"reject"`` still fails this operator; only
-:class:`~airflow.providers.common.ai.operators.llm_branch.LLMBranchOperator` 
turns a
-rejection into a downstream skip. The chosen option is also pre-highlighted as 
the
+timeout expires. ``"reject"`` answers the review with a rejection, which fails 
the task
+unless ``fail_on_reject=False``. The chosen option is also pre-highlighted as 
the

Review Comment:
   This is the only place the page mentions `fail_on_reject`. The rejection 
paragraph further up ("A rejection, or a timeout with `"fail"` or `"reject"`, 
fails the task like any other exception, so `retries` apply") still says it 
unconditionally, but with `fail_on_reject=False` a rejection succeeds the task 
and skips its direct downstream. Could that paragraph get a sentence on it? The 
wording around `llm_branch.rst:148` would fit.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm.py:
##########
@@ -309,6 +324,10 @@ def __init__(
         self.approval_timeout = approval_timeout
         self.on_approval_timeout = on_approval_timeout
         self.allow_modifications = allow_modifications
+        self.fail_on_reject = fail_on_reject
+        self.ignore_downstream_trigger_rules = ignore_downstream_trigger_rules
+        if self._may_review and not fail_on_reject:
+            self._can_skip_downstream = True

Review Comment:
   Mapped tasks don't see this, because both mapped paths take the flag from 
the class, not the instance. A decorated `.expand()` reads 
`operator_class._can_skip_downstream` (task-sdk `bases/decorator.py:728`), 
which is still `False`, so a mapped `@task.llm(require_approval=True, 
fail_on_reject=False)` serializes as not skip-capable. On a mapped TI `skip()` 
only pushes the XCom and returns (it raises only at `map_index == -1`), so the 
task succeeds with `None` and `NotPreviouslySkippedDep` never looks at this 
parent. As far as I can tell from the dep, the downstream then runs on the 
rejected item.
   
   The other way round, `.partial().expand()` uses `issubclass(operator_class, 
SkipMixin)` (`mappedoperator.py:260`), so every mapped LLM operator now turns 
the skip check on, review or not.
   
   Could we set `_can_skip_downstream = True` on the `LLMOperator` class and 
assign `self._can_skip_downstream = self._may_review and not fail_on_reject` 
here? Mapped tasks then over-approximate (an extra XCom read) rather than 
letting a rejection through. A test that serializes a mapped `@task.llm` would 
pin it, and the comment in `_skip_downstream_on_reject` saying `skip()` always 
raises needs the mapped case.



##########
providers/common/ai/docs/operators/llm.rst:
##########
@@ -307,6 +307,12 @@ Parameters
   ``{"id": ..., "name": ...}`` dicts where ``id`` is the auth manager's user 
id.
   ``None`` (default) lets any user with the permission respond.  Fixed at first
   run on Airflow 3.1 to 3.3; replaced on clear or retry from 3.4.  Needs 
Airflow 3.1+.
+- ``fail_on_reject``: If ``False``, a rejected review skips the direct 
downstream
+  tasks except teardowns and the task succeeds, as ``ApprovalOperator`` does.
+  Default ``True``: a rejection fails the task.
+- ``ignore_downstream_trigger_rules``: If ``True``, a rejection that does not 
fail
+  the task skips every downstream task rather than only the direct ones.

Review Comment:
   Teardowns are filtered out on this path too, so "every downstream task 
except teardowns"? The `LLMOperator` docstring has the same wording.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py:
##########
@@ -184,10 +184,10 @@ def execute_complete(
         generated_output: str,
         event: dict[str, Any],
         decision: dict[str, Any] | None = None,
-    ) -> str:
+    ) -> Any:

Review Comment:
   This can only return `str | None` now, so could the annotation say that 
rather than `Any`? Also, the inherited approval parameter list in this class's 
docstring (and in `LLMSchemaCompareOperator` and `LLMFileAnalysisOperator`) 
doesn't mention `fail_on_reject` or `ignore_downstream_trigger_rules` yet. Only 
the branch operator's list was updated.



##########
providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py:
##########
@@ -810,6 +810,22 @@ def test_execute_complete_approved_returns_dict(self):
 
         assert resumed == result.model_dump()
 
+    @mock.patch.object(LLMSchemaCompareOperator, "skip")

Review Comment:
   `LLMSchemaCompareOperator` doesn't override any of the resume methods, so 
this runs the same code as the new `test_llm.py` test, and `mock_skip` is never 
asserted. Maybe drop it, or parametrize one test over the subclasses so 
`LLMFileAnalysisOperator` is covered as well?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to