Add Word document processor plugin - #83
Conversation
Adds a `docx` processor that renders each generated row as a Microsoft Word document, with the file path written back into the dataset so rows and documents stay joined. The plugin ships a WordDocument Pydantic model used twice: as the output_format of an LLM structured column and as the renderer's input contract. Sharing one definition turns unrenderable model output into a column validation error that Data Designer already retries, rather than a downstream parsing failure. Runs at process_after_batch so documents stream out during the run, the row count stays fixed, and the dataset stays resumable. Documents are written to <output_subdir>/<name>/ rather than processors-files/, which Data Designer reads back as parquet datasets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: mvansegbroeck <mvansegbroeck@gmail.com>
nabinchha
left a comment
There was a problem hiding this comment.
Thanks for putting this together, @mvansegbroeck — the renderer/processor split is thoughtful, and the PR does a nice job explaining why DOCX belongs in the generation pipeline.
Summary
This PR adds a data-designer-docx processor that renders structured generated rows into Word documents while retaining a dataset-relative path for each row. The implementation broadly matches the stated intent, but after ten focused review passes I found three correctness/data-safety issues and two API/rendering gaps worth addressing before merge.
Findings
Critical — Let's fix these before merge
plugins/data-designer-docx/src/data_designer_docx/impl.py:178 — Preserve structured document strings
- What:
deserialize_json_valuesrecursively processes the entire record beforeWordDocumentvalidation. Data Designer's structured generators already place decoded mappings in generated rows, so legitimate string leaves that look like JSON scalars are converted:"30"becomes30,"true"becomesTrue, and"null"becomesNone. - Why:
WordDocument.model_validate()rejects those converted values forstrandlist[str]fields. I reproduced this with a generated document mapping containingkey_data.rows=[["30"]]; the processor skipped the row, returneddocx_path=None, and wrote no DOCX file. This is especially likely in the key-data tables the plugin is designed to produce. - Suggestion: Could we preserve an already-decoded document mapping and only JSON-decode
document_columnwhen its top-level value is a serialized JSON string? If recursive decoding is useful for Jinja templates, we can prepare a separate template-rendering record without mutating the value passed toparse_document.
plugins/data-designer-docx/src/data_designer_docx/config.py:78 and impl.py:55 — Keep output paths inside the dataset
- What:
output_dirjoins two configuration-controlled strings,output_subdirand inherited processorname, while the validator only comparesoutput_subdir.strip("/")against three literal names. Values such as../outside,/private/tmp/outside,./processors-files,foo/../parquet-files,tmp-partial-parquet-files, andname="../../outside"are accepted. - Why: These values can escape
base_dataset_path, bypass the managed-directory guard, or place documents in directories Data Designer reads or deletes. In particular, Data Designer removestmp-partial-parquet-filesduring resume, which would leave persisteddocx_pathvalues pointing to deleted documents. - Suggestion: Could we validate both path components, reject every Data Designer-managed directory, and resolve the final output path before writing to assert that it remains beneath
base_dataset_path? Treatingnameas a single safe directory component would also close the second traversal route.
plugins/data-designer-docx/src/data_designer_docx/impl.py:50 — Preserve filename collisions across resume
- What:
_used_filenamesstarts empty for every processor instance and is never seeded from files already present inoutput_dir. A resumed run creates a new processor instance, so new rows can reuse filenames owned by completed batches. - Why: I reproduced this by creating one row with
filename_template="same.docx"and resuming the dataset to two rows. Both rows ended up withdocuments/docs/same.docx, and only one file remained on disk—the resumed row overwrote the completed row's document. The current set also treatsA.docxanda.docxas different even though they collide on default macOS and Windows filesystems. - Suggestion: Could we seed collision tracking from existing output files and compare portable, case-folded collision keys? A deterministic batch/row component or exclusive atomic creation would make resume behavior even safer and avoid overwrites after partial failures.
Warnings — Worth addressing
plugins/data-designer-docx/src/data_designer_docx/config.py:51 — Make the default filename self-contained
- What: The only required data field is
document_column, but the default filename template references{{ doc_id }}. - Why: A valid minimal configuration without an unrelated
doc_idcolumn fails in post-batch template preparation withUserTemplateError, after generation work has already completed. The default configuration therefore is not usable on its own. - Suggestion: Could we use a deterministic default that requires no additional dataset column, or make the filename source explicit and validate its references before generation starts?
plugins/data-designer-docx/src/data_designer_docx/render.py:142 — Apply the footer to generated content
- What: Generated content is appended to the template's final section, while
footer_templatemodifies onlydoc.sections[0]. - Why: With a multi-section template whose final footer is not linked to the first, the generated pages never receive the configured footer. In a two-section reproduction, the footer values were
['override', 'last'], and the generated content belonged to the final section displayinglast. - Suggestion: Could we apply the configured footer to the final generated section, or to every section if the intended contract is a document-wide footer?
What Looks Good
- The separation between
schema.py, the engine-independent renderer, user-facing config, and processor implementation is clean and makes the package easy to evolve. - The PR explains the
process_after_batchstage choice and theprocessors-filesconstraint clearly, including the failure mode that motivated the output layout. - The baseline test suite covers readable DOCX output, tables, styles, core properties, invalid documents, preview/create integration, and path resolution without requiring a model or API key.
- Repository verification is green: all GitHub checks pass; locally,
make lint,make test(338 tests),make validate,make check, and strictmake docspassed. The wheel and sdist also built successfully and passedtwine check.
Verdict
Needs changes. Before merge, I think we should address:
- preservation of string values in decoded structured mappings;
- containment and validation of output paths;
- collision-safe resume behavior;
- the undeclared
doc_iddependency in the default filename; and - footer targeting for generated content in template documents.
This review was generated by an AI assistant.
What
Adds
data-designer-docx, a processor plugin that renders each generated row as aMicrosoft Word document — headings, a front-matter metadata table, body sections
with bullets, a key-data table, page footer, and Word core properties.
The relative path of each file is written back into the dataset (
docx_pathbydefault), so rows and documents stay joined.
Why
Data Designer produces rows. Document pipelines — enterprise RAG ingestion,
document classification, extraction evaluation, DLP tooling — consume
.docx.Today that gap is closed by a post-hoc script outside the config, which only
starts once generation finishes and is easy to forget to run.
Teams also need document corpora they are not allowed to obtain: the real policy
library lives in a customer's SharePoint. Generating them keeps the ground-truth
labels attached, because the sampler controls that produced each document are
already columns in the dataset.
Usage
Files land in
<artifact_path>/<dataset>/documents/word-documents/.How
One model, used twice.
WordDocumentis both theoutput_formatof the LLMstructured column and the input contract of the renderer. Because both ends share
one definition, "the model produced something the renderer can't handle" becomes a
Pydantic validation error on the column — which Data Designer already retries —
instead of a parsing failure downstream. There is no markdown parsing anywhere in
the package: the LLM generates the document's structure, and the renderer walks it.
Post-batch, not after-generation. Documents stream out while the run is still
going, the row count stays fixed as the async engine requires at that stage, and
the dataset stays resumable.
process_after_generationrewrites the final parquetand marks the dataset terminal for resume.
Output location. Documents go to
<output_subdir>/<name>/, never underprocessors-files/. Data Designer reads every directory there back as a parquetdataset, so
.docxfiles placed there makepreview()fail with "Parquet magicbytes not found in footer". A config validator rejects the reserved names. This
was found the hard way and is covered by a regression test.
Ragged tables are repaired, not retried. Structured outputs constrain the shape
of the JSON, not the arithmetic inside it — a model asked for a three-column table
occasionally returns a row with two cells. Rows are padded or truncated to the
header width, since a retry costs a whole document generation.
Layout.
schema.pyholds the contract,render.pyis pure python-docx with noData Designer imports (so layout can be iterated without spending tokens),
config.pyis user-facing,impl.pyis engine-side.A row whose document fails validation is skipped with a null path and a warning;
the rest of the batch still renders.
Validation
The plugin's own suite covers the config validator, filename sanitization
(including path traversal), ragged-table normalization, rendering (headings,
tables, footers, core properties, template style inheritance), and three
integration tests that run the processor through
preview()andcreate()usinga seeded document column — so the suite needs no API key or model access.
make plugin-docsregenerated the site docs;make codeownersregenerated.github/CODEOWNERS.Not registered in
catalog/plugins.json— that is a first-release step, and norelease or publish is being requested here.
🤖 Generated with Claude Code