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


##########
providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py:
##########
@@ -422,6 +432,54 @@ def 
test_docx_missing_raises_optional_feature_exception(self, tmp_path):
             with pytest.raises(AirflowOptionalProviderFeatureException):
                 op.execute(context=MagicMock())
 
+    @pytest.mark.parametrize(
+        ("blocks", "expected"),
+        [
+            pytest.param(
+                ("Intro", "   ", [["Name", "Qty"], ["Apple", "3"]], "Outro"),
+                "Intro\n\n| Name | Qty |\n| Apple | 3 |\n\nOutro",
+                id="tables-in-document-order",
+            ),
+            pytest.param(([["a", "", "c"]],), "| a |  | c |", 
id="empty-cell-keeps-its-column"),
+            pytest.param(
+                ([["h1", "h2"], ["", ""], ["x", "y"]],),
+                "| h1 | h2 |\n| x | y |",
+                id="empty-row-skipped",
+            ),
+            pytest.param(
+                ("Before", [["", ""]], "After", [["Name", "Qty"]]),
+                "Before\n\nAfter\n\n| Name | Qty |",
+                id="empty-table-skipped",
+            ),
+            pytest.param(
+                ([["line1\nline2", "b"]],),
+                "| line1 line2 | b |",
+                id="cell-line-break-stays-on-row",
+            ),
+        ],
+    )
+    def test_docx_tables(self, blocks, expected):

Review Comment:
   No fixture has two adjacent unmerged cells with the same text, so these 
cases would also pass if the `cell is previous_cell` check became a 
text-equality check. With that swap, `[["0", "0"], ["x", "x"]]` renders as `| 0 
|` / `| x |`. Adding a case like `pytest.param(([["0", "0"], ["x", "x"]],), "| 
0 | 0 |\n| x | x |", id="equal-adjacent-unmerged-cells-kept")` would lock in 
the identity check.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py:
##########
@@ -452,19 +454,56 @@ def _parse_pdf_stream(self, stream: BinaryIO) -> 
list[dict[str, Any]]:
 
     def _parse_docx_stream(self, stream: BinaryIO) -> list[dict[str, Any]]:
         """
-        Parse a DOCX stream into documents.
+        Parse a DOCX stream into a single document.
 
-        Extracts paragraph text only. Tables, headers, footers, and footnotes
-        are not included. For richer DOCX parsing, plug in a dedicated
-        extraction tool (``Unstructured``, ``docling``) as a custom parser
-        backend.
+        Paragraphs and tables in the document body are extracted in document
+        order. Each table row becomes one "| cell | cell |" line, and a nested
+        table is flattened into its cell. Headers, footers, footnotes, and
+        content controls are not included.
         """
         try:
             from docx import Document
+            from docx.table import Table
         except ImportError as e:
             raise AirflowOptionalProviderFeatureException(e)
 
         doc = Document(stream)
-        paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
-        text = "\n\n".join(paragraphs)
+        blocks = []
+        for block in doc.iter_inner_content():
+            if isinstance(block, Table):
+                text = "\n".join(f"| {' | '.join(cells)} |" for cells in 
self._get_docx_table_rows(block))

Review Comment:
   Before this PR, a table could never fail a DOCX parse. Now some tables raise 
out of python-docx's `row.cells`. python-docx resolves each vertically merged 
cell by recursing up to the merge's first row. So a vertical merge spanning 
about 1000 rows hits `RecursionError` on 1.2.0 (1100 rows failed after ~20 s; 
300 rows already took 1.1 s), and raising the floor doesn't change that. A 
`vMerge="continue"` cell in the first row raises `ValueError: no tr above 
topmost tr`. Either one fails the whole file, and in directory mode the whole 
batch, where it used to load its paragraphs. Would it be worth catching per 
table here and logging a warning, so one odd table is skipped instead of 
failing the task?



##########
providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py:
##########
@@ -452,19 +454,56 @@ def _parse_pdf_stream(self, stream: BinaryIO) -> 
list[dict[str, Any]]:
 
     def _parse_docx_stream(self, stream: BinaryIO) -> list[dict[str, Any]]:
         """
-        Parse a DOCX stream into documents.
+        Parse a DOCX stream into a single document.
 
-        Extracts paragraph text only. Tables, headers, footers, and footnotes
-        are not included. For richer DOCX parsing, plug in a dedicated
-        extraction tool (``Unstructured``, ``docling``) as a custom parser
-        backend.
+        Paragraphs and tables in the document body are extracted in document
+        order. Each table row becomes one "| cell | cell |" line, and a nested
+        table is flattened into its cell. Headers, footers, footnotes, and
+        content controls are not included.
         """
         try:
             from docx import Document
+            from docx.table import Table
         except ImportError as e:
             raise AirflowOptionalProviderFeatureException(e)
 
         doc = Document(stream)
-        paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
-        text = "\n\n".join(paragraphs)
+        blocks = []
+        for block in doc.iter_inner_content():
+            if isinstance(block, Table):
+                text = "\n".join(f"| {' | '.join(cells)} |" for cells in 
self._get_docx_table_rows(block))
+            else:
+                text = block.text
+            if text.strip():
+                blocks.append(text)
+        text = "\n\n".join(blocks)
         return [{"text": text, "metadata": {}}]
+
+    def _get_docx_table_rows(self, table: Table) -> list[list[str]]:
+        rows = []
+        for row in table.rows:
+            cells: list[str] = []
+            previous_cell = None
+            for cell in row.cells:

Review Comment:
   Even on 1.1.2+/1.2.0, `row.cells` only returns the cells present in the row, 
so a row that starts with `w:gridBefore` shifts its values left under the wrong 
headers. Under `| Product | Revenue | Cost |`, a row that omits Product renders 
as `| 100 | 80 |`. Padding with `[""] * row.grid_cols_before` before the loop 
and `[""] * row.grid_cols_after` after it gives `|  | 100 | 80 |` (both 
properties exist from 1.1.2). Could you add a test with a real 
gridBefore/gridAfter row? None of the current fixtures have one.



##########
providers/common/ai/pyproject.toml:
##########
@@ -147,7 +147,7 @@ dependencies = [
     "llama-index-llms-openai>=0.6.8",
 ]
 "pdf" = ["pypdf>=4.0.0"]
-"docx" = ["python-docx>=1.0.0"]
+"docx" = ["python-docx>=1.1.0"]

Review Comment:
   I think the floor needs to be `>=1.1.2` rather than `>=1.1.0`. In 1.1.0, 
`_Row.cells` is `table.row_cells(self._index)`, which rebuilds the whole 
table's cell list on every call and then slices it by column count. That causes 
two problems for the new loop. It's quadratic: a 500x5 table took 5.6 s to 
parse on 1.1.0 against 0.05 s on 1.2.0, and a 2000-row table took about 90 s. 
It also ignores `w:gridBefore`/`w:gridAfter`, so a row with omitted cells pulls 
cells from the next row. A header, then `[A, B]` with gridAfter=1, then `[C, D, 
E]` comes out as `| A | B | C |` / `| D | E |`. 1.1.1 switched to a per-row 
walk, but it pins `lxml<=4.9.2`, which doesn't build on 3.12, so 1.1.2 is the 
lowest version that works. The lowest-direct-deps CI job resolves 1.1.0, and 
the small regular tables in the tests pass there. The same bump is needed in 
the dev group, docs/index.rst and uv.lock, and README.rst:106 still says 
`>=1.0.0`.



##########
providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py:
##########
@@ -452,19 +454,56 @@ def _parse_pdf_stream(self, stream: BinaryIO) -> 
list[dict[str, Any]]:
 
     def _parse_docx_stream(self, stream: BinaryIO) -> list[dict[str, Any]]:
         """
-        Parse a DOCX stream into documents.
+        Parse a DOCX stream into a single document.
 
-        Extracts paragraph text only. Tables, headers, footers, and footnotes
-        are not included. For richer DOCX parsing, plug in a dedicated
-        extraction tool (``Unstructured``, ``docling``) as a custom parser
-        backend.
+        Paragraphs and tables in the document body are extracted in document
+        order. Each table row becomes one "| cell | cell |" line, and a nested
+        table is flattened into its cell. Headers, footers, footnotes, and
+        content controls are not included.
         """
         try:
             from docx import Document
+            from docx.table import Table
         except ImportError as e:
             raise AirflowOptionalProviderFeatureException(e)
 
         doc = Document(stream)
-        paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
-        text = "\n\n".join(paragraphs)
+        blocks = []
+        for block in doc.iter_inner_content():
+            if isinstance(block, Table):
+                text = "\n".join(f"| {' | '.join(cells)} |" for cells in 
self._get_docx_table_rows(block))
+            else:
+                text = block.text
+            if text.strip():
+                blocks.append(text)
+        text = "\n\n".join(blocks)
         return [{"text": text, "metadata": {}}]
+
+    def _get_docx_table_rows(self, table: Table) -> list[list[str]]:
+        rows = []
+        for row in table.rows:
+            cells: list[str] = []
+            previous_cell = None
+            for cell in row.cells:
+                # python-docx repeats the same _Cell object for every grid 
column a
+                # horizontal merge spans. A vertical merge repeats on each row 
it spans.
+                if cell is previous_cell:
+                    continue
+                previous_cell = cell
+                cells.append(self._get_docx_cell_text(cell))
+            if any(cells):
+                rows.append(cells)
+        return rows
+
+    def _get_docx_cell_text(self, cell: _Cell) -> str:
+        from docx.table import Table

Review Comment:
   `_parse_docx_stream` already imported `Table` inside its `ImportError` 
guard, and this helper is only reachable from there, so this second 
function-body import runs once per cell without adding anything. Could you pass 
`Table` down, or make the two helpers module-level functions that take it as an 
argument?



##########
providers/common/ai/docs/operators/document_loader.rst:
##########
@@ -73,14 +73,28 @@ Install the ``docx`` extra to parse Word documents via
 
     pip install "apache-airflow-providers-common-ai[docx]"
 
-All non-empty paragraphs are concatenated into a single document per file.
+Paragraphs and tables in the document body are concatenated, in document
+order, into a single document per file. Empty paragraphs and table rows are
+skipped. Each table row becomes one line:
+
+.. code-block:: text
+
+    Quarterly results
+
+    | Region | Revenue |
+    | EMEA | 1.2M |
+
+A cell merged across columns appears once. A cell merged down several rows is
+repeated on each of those rows, so every row still reads on its own. A table
+nested inside a cell is flattened into that cell, with ``/`` between its cells
+and ``;`` between its rows.
 
 .. note::
 
-   DOCX extraction reads paragraph text only. Tables, headers, footers, and
-   footnotes are not included. For richer DOCX parsing, use a dedicated
-   extraction tool (``Unstructured``, ``docling``) as a custom parser
-   backend.
+   Headers, footers, footnotes, and content controls are not included. For

Review Comment:
   Now that content controls are listed too, this reads as a full list, but 
text boxes and pending tracked insertions (`w:ins`) are also dropped: `Base ` + 
an inserted run comes out as `'Base '`. That predates this PR, but maybe phrase 
it positively instead? For example: "Only paragraphs and tables in the document 
body are read. Headers, footers, footnotes, content controls, text boxes, and 
pending tracked insertions are not included."



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