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


##########
providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py:
##########
@@ -30,25 +33,43 @@ def rehydrate_pydantic_output(
     serialize_output: bool,
 ) -> Any:
     """
-    Turn a JSON string back into a value of ``output_type``.
+    Turn the reviewed string (JSON, or bare text for str-valued types) back 
into ``output_type``.
 
     Used by the HITL/approval paths in ``LLMOperator`` and ``AgentOperator``
     that round-trip the output through a string when deferring to a human
     reviewer. ``str`` outputs pass through unchanged; any other ``output_type``
     (``BaseModel`` subclass, ``int``, ``list[str]``, ...) is validated with a
-    pydantic ``TypeAdapter``. When validation fails (reviewer edited the string
-    into something the type rejects), returns ``raw`` unchanged.
+    pydantic ``TypeAdapter``, first as JSON and then as bare text.
+    An ``output_type`` pydantic has no schema for returns ``raw`` unchanged.
 
     When ``serialize_output`` is ``True``, returns the model dumped to a
     ``dict`` -- matches the operator's ``serialize_output=True`` opt-in for
     consumers that want the dict shape.
+
+    :raises ReviewedOutputValidationError: If ``output_type`` is not ``str`` 
and ``raw``
+        (typically a reviewer's edit) is neither valid JSON for it nor valid 
as plain text.
     """
     if output_type is str:
         return raw
     try:
-        rehydrated = TypeAdapter(output_type).validate_json(raw)
-    except (ValidationError, ValueError, TypeError):
+        adapter: TypeAdapter[Any] = TypeAdapter(output_type)
+    except PydanticSchemaGenerationError:

Review Comment:
   This passthrough doesn't catch output functions: `TypeAdapter(fn)` builds a 
`call` schema instead of raising, so both attempts validate the text as the 
function's arguments. With `def clean(text: str) -> str` as `output_type`, an 
unedited approval of `"A"` now raises (`call[clean] Invalid JSON`), where main 
returned the text. The description leaves output functions to #70132, but this 
change takes them from working to failing on every approval. Could a `call` 
schema (`adapter.core_schema["type"] == "call"`) return `raw` here until #70132 
lands?



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py:
##########
@@ -268,8 +270,13 @@ def regenerate_with_feedback(self, *, feedback: str, 
message_history: Any) -> tu
 
     @staticmethod
     def _to_string(output: Any) -> str:
+        """Serialize an output for review as JSON, or ``str()`` when pydantic 
cannot."""
         if isinstance(output, BaseModel):
             return output.model_dump_json()

Review Comment:
   An unedited approval now fails for any model with field aliases. 
`model_dump_json()` writes field names (`{"user_name": "kax"}`) but 
`validate_json` expects the alias (`userName`), so `rehydrate_pydantic_output` 
raises `ReviewedOutputValidationError` on a plain Approve. Before this PR that 
case passed a string downstream; now the task fails, a retry re-runs the model 
and fails the same way, and `on_approval_timeout="approve"` fails with nobody 
involved.
   
   The serializer `LLMOperator` uses is the one in `approval.py` (L198-201), 
which has the same gap. Passing `by_alias=True` to both `model_dump_json` and 
`dump_json` in both places fixes it (I checked a single aliased model and a 
list of them), and a test with an aliased model and an unedited approval would 
pin it.



##########
providers/common/ai/tests/unit/common/ai/operators/test_agent.py:
##########
@@ -1037,6 +1038,31 @@ def 
test_execute_with_enable_hitl_review_delegates_to_run_hitl_review(
         assert result == "Approved output"
         mock_run_hitl.assert_called_once_with(op, context, "Initial output", 
message_history=msg_history)
 
+    @pytest.mark.skipif(
+        not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible 
with Airflow >= 3.1.0"
+    )
+    
@patch("airflow.providers.common.ai.operators.agent.AgentOperator.run_hitl_review",
 autospec=True)
+    @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", 
autospec=True)
+    def test_execute_with_hitl_fails_on_edit_invalid_for_output_type(

Review Comment:
   `AgentOperator`'s review loop has no edit path (`HumanActionData` only 
carries action, feedback and iteration), so `run_hitl_review` can't return 
reviewer prose like this. On this operator the error only fires when the 
serialized output doesn't validate back, such as an aliased model or an output 
function. Could the test be named for that and use one of those cases?



##########
providers/common/ai/src/airflow/providers/common/ai/operators/agent.py:
##########
@@ -1428,7 +1428,4 @@ def regenerate_with_feedback(self, *, feedback: str, 
message_history: Any) -> tu
         # shared, is the seeded cumulative object, not what this call alone 
contributed.
         log_run_summary(self.log, result, usage=regen_usage)
 
-        output = result.output
-        if isinstance(output, BaseModel):
-            output = output.model_dump_json()
-        return str(output), result.all_messages()
+        return self._to_string(result.output), result.all_messages()

Review Comment:
   Nothing tests this line. The existing `regenerate_with_feedback` tests only 
use `str` and `BaseModel` outputs, which the old body handled too, so reverting 
it to `str(output)` leaves the suite green. A `list[str]` case asserting 
`'["a","b"]'` would cover the regenerated-output half of the fix.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py:
##########
@@ -30,25 +33,43 @@ def rehydrate_pydantic_output(
     serialize_output: bool,
 ) -> Any:
     """
-    Turn a JSON string back into a value of ``output_type``.
+    Turn the reviewed string (JSON, or bare text for str-valued types) back 
into ``output_type``.
 
     Used by the HITL/approval paths in ``LLMOperator`` and ``AgentOperator``
     that round-trip the output through a string when deferring to a human
     reviewer. ``str`` outputs pass through unchanged; any other ``output_type``
     (``BaseModel`` subclass, ``int``, ``list[str]``, ...) is validated with a
-    pydantic ``TypeAdapter``. When validation fails (reviewer edited the string
-    into something the type rejects), returns ``raw`` unchanged.
+    pydantic ``TypeAdapter``, first as JSON and then as bare text.
+    An ``output_type`` pydantic has no schema for returns ``raw`` unchanged.
 
     When ``serialize_output`` is ``True``, returns the model dumped to a
     ``dict`` -- matches the operator's ``serialize_output=True`` opt-in for
     consumers that want the dict shape.
+
+    :raises ReviewedOutputValidationError: If ``output_type`` is not ``str`` 
and ``raw``
+        (typically a reviewer's edit) is neither valid JSON for it nor valid 
as plain text.
     """
     if output_type is str:
         return raw
     try:
-        rehydrated = TypeAdapter(output_type).validate_json(raw)
-    except (ValidationError, ValueError, TypeError):
+        adapter: TypeAdapter[Any] = TypeAdapter(output_type)
+    except PydanticSchemaGenerationError:
+        # Nothing to validate against, so the reviewed text is all there is.
         return raw
+    try:
+        rehydrated = adapter.validate_json(raw)
+    except (ValidationError, ValueError, TypeError) as exc:
+        # Bare text is how a str-valued Literal, Enum or ``str | None`` output 
is carried through review.
+        try:
+            rehydrated = adapter.validate_python(raw)
+        except (ValidationError, ValueError, TypeError):
+            type_name = (
+                output_type if get_origin(output_type) else 
getattr(output_type, "__name__", output_type)
+            )
+            raise ReviewedOutputValidationError(
+                f"The reviewed output could not be converted to the 
output_type {type_name}. "
+                f"Received {raw!r}. {exc}"

Review Comment:
   `exc` is the `validate_json` error, so for the bare-text types the docs now 
describe, the message points the reviewer at JSON. `Literal["a", "b"]` with the 
edit `"c"` reports `Invalid JSON: expected value at line 1 column 1` and drops 
the python-mode error, which says `Input should be 'a' or 'b'`. Reporting the 
second error when the first one is only `json_invalid` would be more useful. 
`{raw!r}` also puts the whole LLM output into the task error, so truncating it 
would keep a long output from flooding the log.



##########
providers/common/ai/tests/unit/common/ai/utils/test_output_type.py:
##########
@@ -48,15 +56,31 @@ def test_returns_raw_for_str_output_type(self):
     def test_validates_other_types_with_type_adapter(self, output_type, raw, 
expected):
         assert rehydrate_pydantic_output(output_type, raw, 
serialize_output=False) == expected
 
-    def test_returns_raw_when_type_adapter_rejects(self):
-        result = rehydrate_pydantic_output(int, "not-a-number", 
serialize_output=False)
-        assert result == "not-a-number"
+    @pytest.mark.parametrize(
+        ("output_type", "raw", "expected"),
+        [
+            (Literal["a", "b"], "a", "a"),
+            (str | None, "hello", "hello"),
+            (Color, "red", Color.RED),

Review Comment:
   `Color` subclasses `str`, so `Color.RED == "red"` and this case passes even 
when the raw string comes back unconverted (it passes on main). Asserting 
`type(result) is Color` would make it check the conversion.



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