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


##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -165,6 +177,14 @@ def defer_for_approval(
             params=hitl_params,
         )
 
+        self.subject = subject
+        self.body = body
+        for notifier in self.approval_notifiers:
+            try:
+                notifier(context)

Review Comment:
   Two things about this loop, the first with a real consequence. 
`BaseNotifier.__call__` runs `_update_context` and `render_template_fields` 
outside its own try (`notifier.py:135-141`), so a template error surfaces here 
rather than inside the notifier, and `DAG.template_undefined` defaults to 
`jinja2.StrictUndefined` (`dag.py:472`). Measured on a DAG-bound operator: `{{ 
task.subject }}` sends, while `{{ task.bodyy }}` and `{{ nonexistent_var }}` 
each raise `UndefinedError`, get swallowed here, and leave `sent == []` with 
the review opened and the task parked. So a one-character typo sends nothing, 
deterministically, on every run. `HITLDetail` has no delivery-status column 
(`models/hitl.py:140-164`), so the Required Actions page looks like any healthy 
pending review, the only trace is two ERROR lines in the task log, and with 
`approval_timeout=None` (the documented default) it waits indefinitely -- which 
is the pre-PR situation from this PR's own Why, reached through the commonest au
 thoring mistake. I asked for the swallow in round 1 and am not asking for it 
back: a transient SMTP outage should not fail the task. The point is that one 
`try` spans both framework rendering and user-code delivery while only delivery 
is plausibly transient -- should the swallow be scoped to `notify`, or the 
permanence at least be made visible in the log? Separately and much smaller: 
206112f8 passed `{**context, ...}` here and this commit swapped in the live 
dict, so each notifier's `template_fields` now land in the dict the next one 
renders against. Reproduced, though it needs a later notifier to reference a 
bare name an earlier one declares, which no stock notifier does, and 
`HITLOperator` hands over the live context too -- the only reason to mention it 
is that keeping the copy cost nothing.



##########
providers/common/ai/docs/operators/llm.rst:
##########
@@ -205,6 +205,14 @@ approving with ``allow_modifications=True``, and set a 
deadline with
     :start-after: [START howto_operator_llm_approval]
     :end-before: [END howto_operator_llm_approval]
 
+A pending review is not surfaced as a notification.  Pass
+``approval_notifiers`` to tell the reviewers about it through any Airflow
+notifier (Slack, email, ...), the way
+:class:`~airflow.providers.standard.operators.hitl.HITLOperator` does with
+``notifiers``.  The notifiers run once the review is open and can reference
+the review ``{{ task.subject }}`` and ``{{ task.body }}`` in their templates.

Review Comment:
   `{{ task.subject }}` rather than the bare `{{ subject }}` is the 
counterintuitive half of this contract, and prose here is the only place it is 
stated. No example DAG uses `approval_notifiers` at all, while the operator 
this mirrors has one: `example_hitl_operator.py` wires a `LocalLogNotifier` 
templating `{{ task.subject }}` / `{{ task.body }}` into every `HITLOperator`. 
A couple of lines inside the existing `[START howto_operator_llm_approval]` 
block in `example_llm.py` would pin the spelling where people actually copy 
from. `llm_sql.rst` has the same shape, and this diff moved it there: the colon 
that introduces the `exampleinclude` used to close "re-validated against the 
same safety rules automatically:", which the example does show, and now closes 
the sentence that was edited to add `approval_notifiers` 
(`llm_sql.rst:143-146`) while `[START howto_operator_llm_sql_approval]` sets 
`require_approval`, `approval_timeout` and `allow_modifications` and no 
notifier. So both pages no
 w promise an example of this parameter and render one without it. Users copy 
example DAGs verbatim, which is the standing reason to want at least one that 
demonstrates the parameter rather than only prose describing it. The gap is 
wider than the operator pages too: the five `@task.llm*` decorators each 
forward `**kwargs` to their operator, so they accept and honour 
`approval_notifiers`, and no decorator doc mentions it. One more line worth 
having on this page: the default `body` is the prompt and the output in a code 
fence (`approval.py:150`), and `prompt` is a rendered template field, so 
anything templated into it is already substituted by the time `{{ task.body }}` 
reaches a notifier. The same text already goes to the HITL row, so this is not 
new exposure, but the review UI sits inside Airflow's auth boundary and a Slack 
channel does not -- worth suggesting `{{ task.subject }}` plus a link when the 
prompt or output is sensitive.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm.py:
##########
@@ -29,7 +29,7 @@
 from airflow.providers.common.ai.mixins.approval import LLMApprovalMixin
 from airflow.providers.common.ai.utils.logging import log_run_summary
 from airflow.providers.common.ai.utils.output_type import 
rehydrate_pydantic_output
-from airflow.providers.common.compat.sdk import BaseOperator
+from airflow.providers.common.compat.sdk import BaseNotifier, BaseOperator

Review Comment:
   This makes the module unimportable on Airflow 3.0.0 and 3.0.1. Pre-PR this 
line imported only `BaseOperator`. `BaseNotifier` resolves through 
`compat/sdk.py:207` as `("airflow.sdk", "airflow.notifications.basenotifier")`, 
and I checked both candidates at the release tags: on 3.0.0 and 3.0.1 
`airflow.sdk`'s lazy map points `BaseNotifier` at `.definitions.notifier`, 
which exists in neither (it was repointed to `.bases.notifier` in 3.0.2), and 
`airflow/notifications/basenotifier.py` is absent from every 3.x tag, so the 
second candidate is dead across all of 3.x. `_compat_utils` catches both and 
then raises `ImportError("Could not import 'BaseNotifier' from any of: ...")`. 
The provider declares `apache-airflow>=3.0.0` (`pyproject.toml:70`) with no 
import-time gate, and neither `standard>=1.12.1` nor `common-compat>=1.15.0` 
lifts that floor (both are `apache-airflow>=2.11.0`), so the combination 
installs. This module is the base of all four operator subclasses and the five 
`@task.llm*`
  decorators, so on those two cores any DAG touching the provider's LLM surface 
fails to parse, approvals or not. `from 
airflow.providers.common.compat.notifier import BaseNotifier` avoids it -- that 
module imports `airflow.sdk.bases.notifier` directly, which is present at 
3.0.0. Raising the declared floor past 3.0.1 would be an equally honest answer 
if those cores are not really supported; the concern is that the PR currently 
decides it silently. (`approval.py:39` has the same import under 
`TYPE_CHECKING`, so it is harmless today -- worth moving only for consistency.)



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm.py:
##########
@@ -138,6 +142,14 @@ def __init__(
         self.require_approval = require_approval
         self.approval_timeout = approval_timeout
         self.allow_modifications = allow_modifications
+        self.approval_notifiers = (
+            [approval_notifiers]
+            if isinstance(approval_notifiers, BaseNotifier)
+            else list(approval_notifiers or [])

Review Comment:
   Follow-up on my round-2 ask: the `list(...)` landed, but the guard behind it 
only catches a wrong *element*, because normalization runs first. Ran the cases 
against this constructor: `approval_notifiers=send_slack_alert` (a bare 
callable, and the likeliest mistake given Airflow's callback params take plain 
callables) dies on this line as `TypeError: 'function' object is not iterable` 
and never reaches the guard; `5` gives `'int' object is not iterable`; a str is 
split into characters, so `"not-a-notifier"` reports `got 'n'` and 
`"my_notifier"` reports `got 'm'`; a dict iterates keys and reports `got 'a'`. 
Only `[object()]` produces the intended message. The test pins the weak half 
rather than catching it -- `test_rejects_non_notifier_approval_notifiers` 
passes `"not-a-notifier"` and its `match=` stops before the offending value, so 
`got 'n'` is green, and no case covers a non-iterable at all. Rejecting the 
scalar case before iterating closes both symptoms, with `str` ruled out exp
 licitly ahead of that check since `str` is itself `Iterable` and would 
otherwise still split. Worth reaching for `Iterable` rather than `Sequence` 
there: a `set` and a generator are both accepted today, and a `Sequence` bound 
would quietly stop taking them.



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -165,6 +177,14 @@ def defer_for_approval(
             params=hitl_params,
         )
 
+        self.subject = subject

Review Comment:
   A reviewer gets notified about one generation and approves another, and the 
feature's success condition is the bug's trigger. The docstring above says 
notifiers exist so "a reviewer learns about it without watching the Required 
Actions page", and the PR's Why is that reviews sat until someone happened to 
look, so acting on the notification instead of opening the UI is the intended 
workflow. A retry or clear is exactly when the two disagree: 
`prepare_db_for_next_try` rotates `task_instance.id` (`taskinstance.py:1076`), 
`hitl_detail_ti_fkey` is `onupdate="CASCADE"` (`hitl.py:171-176`) so the review 
row follows the new id, and neither upsert branch rewrites 
`subject`/`body`/`params` -- the no-response branch updates nothing at all and 
the with-response branch clears only the four response columns 
(`execution_api/routes/hitl.py:71-93`). Attempt 2's notification therefore 
describes its own generation while the review page still shows attempt 1's. 
Only the retry has to happen for this t
 o bite; the outputs differing is the default for a non-deterministic model, 
not a second condition. Aggravated, though pre-existing and not this PR's to 
fix: `params` survives too, so with `allow_modifications=True` the form is 
pre-filled with attempt 1's output, and approving unedited makes `modified != 
generated_output` true, so `execute_complete` returns attempt 1's text as the 
task result (approval.py:282-284). Should the notification carry the subject 
and body the review actually holds rather than the freshly computed ones? The 
authoritative row is on the wire as `HITLDetailRequestResult` 
(`comms.py:788-794`), but `upsert_hitl_detail` discards it and is typed `-> 
None` (`task-sdk/.../execution_time/hitl.py:39-47`), so consuming it is a Task 
SDK change -- if that is out of scope here, a docs note saying a retry 
re-notifies with regenerated content while the review keeps the original is 
provider-only and gets the same honesty. The retry really does re-run 
`execute`: `HITLTimeoutE
 rror` extends `HITLTriggerEventError(Exception)`, so an approval timeout is an 
ordinary retryable failure, and `clear_next_method_args` unsets 
`next_method`/`next_kwargs` so the retry starts from the top 
(`taskinstance.py:1572-1577`). A 3.3+ resume and a legacy triggerer restart 
both come back through `next_method` instead, so neither re-notifies.



-- 
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