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


##########
providers/common/ai/tests/unit/common/ai/mixins/test_approval.py:
##########
@@ -174,6 +178,42 @@ def test_array_schema_passes_list_param_value(
         defer_kwargs = approval_op_with_modifications.defer.call_args[1]
         assert defer_kwargs["kwargs"]["generated_output"] == '["task_a"]'
 
+    @patch(HITL_TRIGGER_PATH, autospec=True)
+    @patch(UPSERT_HITL_PATH)
+    def test_notifiers_fire_once_the_review_is_open(self, mock_upsert, 
mock_trigger_cls, context):
+        notifier = MagicMock(spec=BaseNotifier)
+        order = MagicMock()
+        order.attach_mock(mock_upsert, "open_review")
+        order.attach_mock(notifier, "notify")
+        op = FakeOperator(approval_notifiers=[notifier])
+
+        op.defer_for_approval(context, "output")
+
+        notifier.assert_called_once_with(

Review Comment:
   `MagicMock(spec=BaseNotifier)` never executes `BaseNotifier.__call__`, so 
these cases prove a dict is handed to the notifier, not that a notifier can 
read anything back out of it, which is how the `subject` shadowing above 
survives a green suite. A small real `BaseNotifier` subclass that declares 
`subject` in its own `template_fields` and asserts on the rendered value would 
have caught it. Separately, every case here passes a single-element list, so 
moving the `try` outside the `for` loop keeps the whole file green; the one 
thing the per-iteration `try` buys, a dead notifier not silencing the one 
beside it, has no coverage. One case built from `[failing, healthy]` would pin 
that.



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

Review Comment:
   `BaseNotifier.__call__` runs `_update_context()` before 
`render_template_fields()`, and `_update_context` does `context.update((f, 
getattr(self, f)) for f in self.template_fields)`, so merely declaring 
`subject` or `body` in a notifier's own `template_fields` overwrites the review 
value before anything renders. `SmtpNotifier` declares `subject` and 
`AppriseNotifier` declares `body`, which are the two names the new docs line 
tells people to use. The most natural recipe is the one that fails silently: 
running a notifier that carries `SmtpNotifier`'s `template_fields` with 
`html_content="<b>{{ subject }}</b><pre>{{ body }}</pre>"` and no `subject` of 
its own renders `<b>None</b>`, because `getattr(self, "subject")` merges the 
unset field's `None` over the review headline; set `subject="{{ subject }}"` 
explicitly and it comes back as the literal `{{ subject }}` instead. A 
Slack-shaped notifier (`text="{{ subject }}"`) renders correctly, so the 
promise holds everywhere except the notif
 iers whose own field names collide. `HITLOperator` never hits this because it 
keeps them as operator attributes, which is why `example_hitl_operator.py` 
templates `{{ task.subject }}` / `{{ task.body }}` rather than the bare names; 
assigning `self.subject` / `self.body` before the loop, or renaming the 
injected keys to something that cannot collide, would put them out of 
`_update_context`'s reach.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm.py:
##########
@@ -138,6 +142,9 @@ 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 approval_notifiers or []

Review Comment:
   The `else` branch passes any truthy non-`BaseNotifier` straight through, and 
the `for` statement that consumes it sits outside the `try` in 
`defer_for_approval`, so a bare function or `partial` escapes as `TypeError: 
... is not iterable` after `agent.run_sync()` has been billed and 
`upsert_hitl_detail` has already written the review row, while a plain string 
iterates per character, turns each one into a caught `TypeError` in the worker 
log, sends nothing, and parks the task as if all was well. The core submit 
handler gates its resume on `AWAITING_INPUT`/`DEFERRED`, so in the first case 
the reviewer can still answer that orphaned review and resume nothing. 
`HITLOperator` uses a byte-identical expression, so the shape is defensible as 
parity -- what makes it worth tightening here is the same asymmetry the failure 
path already landed on: its loop runs before anything expensive or 
irreversible, this one runs after both. Would wrapping the `else` in 
`list(...)` and rejecting a non-`Bas
 eNotifier` element here be worth it, so the mistake surfaces at parse time 
instead?



##########
providers/common/ai/src/airflow/providers/common/ai/operators/llm_sql.py:
##########
@@ -83,7 +83,8 @@ class LLMSQLQueryOperator(LLMOperator):
 
     Human-in-the-Loop approval parameters are inherited from
     :class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
-    (``require_approval``, ``approval_timeout``, ``allow_modifications``).
+    (``require_approval``, ``approval_timeout``, ``allow_modifications``,
+    ``approval_notifiers``).

Review Comment:
   `llm_sql.rst` is the one operator page the doc sweep missed. Its 
Human-in-the-Loop Approval section still names only `require_approval` and 
`allow_modifications` inline, while `llm.rst`, `llm_branch.rst`, 
`llm_file_analysis.rst`, and `llm_schema_compare.rst` all picked up 
`approval_notifiers` in this PR. One sentence pointing at :doc:`llm` would even 
it up.



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