From fc6f8f57918dab1ce7cb7b4259823303764666e2 Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Fri, 14 Aug 2026 08:43:54 +1000 Subject: [PATCH 1/8] Fix validation logic for app registration and improve error handling - Ensure appClientId and tenantId are always required, regardless of directConnectUrl presence. - Adjusted resolveConfig() to prevent early exits that could lead to misleading errors. - Added 4 regression tests to enhance coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../README.md | 62 ++++++++ .../SKILL.md | 144 ++++++++++++++++++ .../metadata.json | 11 ++ 3 files changed, 217 insertions(+) create mode 100644 submissions/word-document-generator-from-template/README.md create mode 100644 submissions/word-document-generator-from-template/SKILL.md create mode 100644 submissions/word-document-generator-from-template/metadata.json diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md new file mode 100644 index 00000000..8cdf366f --- /dev/null +++ b/submissions/word-document-generator-from-template/README.md @@ -0,0 +1,62 @@ +# Word Document Generator from Template + +Fill an uploaded Microsoft Word template using information from the user and +approved agent knowledge sources. The template keeps control of structure, +branding, styles, tables, headers, and footers. The skill writes a **new** +DOCX — it never overwrites the original template. + +Instruction-only — no scripts or extra packages. + +## When to use it + +Ask the agent to create, draft, or compile a document from a template, for +example: + +- an information security policy from the corporate policy template; +- a statement of work from a standard SOW template; +- a board paper, briefing, or status report from a branded Word file; +- a procedure or SOP that must keep the official heading structure. + +## Before you start + +Provide: + +| Input | Why it matters | +| --- | --- | +| Word template (`.docx`) | Controls layout, placeholders, and branding | +| Document title and purpose | Sets the draft intent | +| Intended audience | Tones the language | +| Requirements | Anything the template must cover | +| Approved knowledge sources | The only facts the agent may use | +| Output filename | Name of the new DOCX | + +If a required fact is not in approved sources, the agent writes +`Not specified in approved sources` instead of inventing it. + +## How it works + +1. Finds the uploaded Word template at runtime. +2. Inspects placeholders, sections, tables, headers, and footers. +3. Pulls facts from approved knowledge and user-supplied files. +4. Builds a structured JSON object that matches the template fields. +5. Validates required sections, source-backed statements, and repeating rows. +6. Populates the template while preserving styles and layout. +7. Saves a new DOCX and returns a short generation summary. + +## Example request + +> Use the attached Information Security Policy template. Draft version 0.1 for +> internal staff. Pull content only from our approved security knowledge. +> Save as `Information-Security-Policy-v0.1.docx`. + +The agent returns the completed Word file plus a summary of what was filled, +what was missing, and which sources were used. + +## Good to know + +- Output is a **draft** until a human reviews and approves it. +- Unsupported statements are marked for review, not presented as fact. +- The template is not treated as a knowledge source unless you say so. +- Sections are not added or removed unless you explicitly ask. +- If no `.docx` template is available, generation stops with: + `The required Word template was not supplied or could not be accessed.` diff --git a/submissions/word-document-generator-from-template/SKILL.md b/submissions/word-document-generator-from-template/SKILL.md new file mode 100644 index 00000000..27c9bad0 --- /dev/null +++ b/submissions/word-document-generator-from-template/SKILL.md @@ -0,0 +1,144 @@ +--- +name: word-document-generator-from-template +description: Generates a complete Word document by using an uploaded Word template and approved knowledge sources. Use when a user asks to create, draft, or compile a document from a template. +--- +# Word Document Generator from Template + +## Purpose + +Generate a complete Microsoft Word document using: + +- a Word template supplied at runtime; +- information provided by the user; +- approved agent knowledge sources; and +- files supplied with the request. + +The uploaded template controls the document structure, formatting, headings, tables, headers, footers, and branding. + +## Required inputs + +Before generating the document, identify: + +- the Word template to use; +- the document title and purpose; +- the intended audience; +- any user-provided requirements; +- the approved knowledge sources to use; and +- the required output filename. + +If required information is unavailable, use: + +`Not specified in approved sources` + +Do not invent facts, dates, owners, approvals, obligations, or organizational information. + +## Instructions + +1. Locate the Word template supplied at runtime. +2. Inspect the template to identify: + - placeholders; + - headings and sections; + - tables; + - repeating content areas; + - headers and footers; and + - required document metadata. +3. Retrieve relevant information from the approved knowledge sources. +4. Generate content for each document section separately. +5. Create a structured JSON object matching the template fields. +6. Validate that: + - required sections are present; + - required fields have values; + - generated statements are supported by approved sources; + - repeating items are represented as arrays; and + - missing information is clearly identified. +7. Populate the uploaded Word template using the validated JSON. +8. Preserve the template's: + - styles; + - layout; + - branding; + - section breaks; + - headers and footers; and + - table formatting. +9. Save the completed document as a new DOCX file. +10. Never overwrite the original template. +11. Check the completed document for: + - unresolved placeholders; + - missing sections; + - broken tables; + - duplicated content; + - unsupported statements; and + - formatting or pagination problems. +12. Return the completed Word document and a short generation summary. + +## Generation rules + +- Use only information from approved knowledge sources or user-supplied files. +- Generate long documents section by section rather than in one response. +- Keep the structured JSON as the intermediate source of truth. +- Use clear, professional, organization-appropriate language. +- Preserve mandatory wording found in approved sources. +- Do not treat the uploaded template as a knowledge source unless instructed. +- Do not add new sections unless required to complete the template. +- Do not remove sections from the template without an explicit instruction. +- Record the sources used for each major section when source information is available. + +## Template handling + +The template will be uploaded or retrieved at runtime. + +Search the runtime working directory for the supplied `.docx` file. If more than one Word template is available, select the template identified in the user request. + +If the required template cannot be found, stop document generation and report: + +'The required Word template was not supplied or could not be accessed.' + +## Structured JSON + +Create JSON that reflects the uploaded template. Use: + +- strings for individual fields; +- objects for document sections; +- arrays for repeating tables or content blocks; and +- source identifiers for traceability. + +Example: + +{ + "document": { + "title": "Information Security Policy", + "owner": "Information Security", + "version": "0.1", + "status": "Draft" + }, + "sections": { + "executive_summary": { + "content": "Generated section content", + "source_ids": ["SRC-001"] + }, + "purpose": { + "content": "Generated section content", + "source_ids": ["SRC-001", "SRC-002"] + } + }, + "requirements": [ + { + "id": "REQ-001", + "requirement": "Generated requirement", + "owner": "System owner", + "evidence": "Approval record", + "source_ids": ["SRC-002"] + } + ], + "sources": [ + { + "source_id": "SRC-001", + "title": "Approved source document" + } + ] +} + +Adapt the JSON fields to the actual placeholders and structure found in the runtime template. + +## Quality and safety + +The generated document is a draft until reviewed and approved. If a statement cannot be supported by an approved source, do not present it as fact. Mark it for human review. diff --git a/submissions/word-document-generator-from-template/metadata.json b/submissions/word-document-generator-from-template/metadata.json new file mode 100644 index 00000000..6a97eb0a --- /dev/null +++ b/submissions/word-document-generator-from-template/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "Word Document Generator from Template", + "description": "Fill an uploaded Word template from approved knowledge sources and user files. Preserves branding, styles, and layout; never invents facts; returns a new DOCX plus a short generation summary.", + "platforms": ["Copilot Studio"], + "tags": ["word", "documents", "templates", "docx", "knowledge", "drafting", "compliance"], + "author": "Nazish Qasim", + "authorUrl": "https://github.com/nazishqassim", + "version": "1.0.0", + "createdAt": "2026-08-14", + "updatedAt": "2026-08-14" +} From f3ac5acca808b5c5cbaa0f8fee45c9c6d60524f1 Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Fri, 14 Aug 2026 09:14:52 +1000 Subject: [PATCH 2/8] Enhance Word Document Generator skill to utilize prior tool and connector results - Updated the skill description to include user input and results from previous tool or connector calls. - Modified the README to clarify the use of prior results and their integration into document generation. - Adjusted metadata to reflect the inclusion of connector results as valid sources. - Improved validation and generation rules to ensure accurate sourcing of information from approved knowledge, user files, and prior results. --- .../README.md | 36 ++++++++++++------- .../SKILL.md | 27 +++++++++----- .../metadata.json | 4 +-- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md index 8cdf366f..4fcac1a5 100644 --- a/submissions/word-document-generator-from-template/README.md +++ b/submissions/word-document-generator-from-template/README.md @@ -1,9 +1,11 @@ # Word Document Generator from Template -Fill an uploaded Microsoft Word template using information from the user and -approved agent knowledge sources. The template keeps control of structure, -branding, styles, tables, headers, and footers. The skill writes a **new** -DOCX — it never overwrites the original template. +Fill an uploaded Microsoft Word template using information from the user, +approved agent knowledge sources, and **results from prior tool or connector +calls** (Dataverse, SharePoint, CRM, and other Copilot Studio actions). The +template keeps control of structure, branding, styles, tables, headers, and +footers. The skill writes a **new** DOCX — it never overwrites the original +template. Instruction-only — no scripts or extra packages. @@ -12,10 +14,11 @@ Instruction-only — no scripts or extra packages. Ask the agent to create, draft, or compile a document from a template, for example: -- an information security policy from the corporate policy template; +- a leave policy from the corporate policy template; - a statement of work from a standard SOW template; -- a board paper, briefing, or status report from a branded Word file; -- a procedure or SOP that must keep the official heading structure. +- a report, briefing, or paper from a branded Word file; +- a procedure or SOP that must keep the official heading structure; +- a status report whose tables come from Dataverse, SharePoint, or another connector called earlier in the conversation. ## Before you start @@ -27,34 +30,41 @@ Provide: | Document title and purpose | Sets the draft intent | | Intended audience | Tones the language | | Requirements | Anything the template must cover | -| Approved knowledge sources | The only facts the agent may use | +| Approved knowledge sources | Policies, manuals, and other grounded content | +| Prior tool / connector results | Records, lists, and fields already retrieved this conversation | | Output filename | Name of the new DOCX | -If a required fact is not in approved sources, the agent writes -`Not specified in approved sources` instead of inventing it. +Approved sources include knowledge, uploaded files, **and** data returned by +upstream tools or connectors. If a required fact is not in those sources, the +agent writes `Not specified in approved sources` instead of inventing it. ## How it works 1. Finds the uploaded Word template at runtime. 2. Inspects placeholders, sections, tables, headers, and footers. -3. Pulls facts from approved knowledge and user-supplied files. +3. Pulls facts from approved knowledge, user-supplied files, and prior tool or connector results. 4. Builds a structured JSON object that matches the template fields. 5. Validates required sections, source-backed statements, and repeating rows. 6. Populates the template while preserving styles and layout. 7. Saves a new DOCX and returns a short generation summary. -## Example request +## Example requests > Use the attached Information Security Policy template. Draft version 0.1 for > internal staff. Pull content only from our approved security knowledge. > Save as `Information-Security-Policy-v0.1.docx`. +> Get the open accounts from Dataverse, then fill the attached status-report +> template. Use those records for the table and our knowledge base for the +> narrative. Save as `Q3-Account-Status.docx`. + The agent returns the completed Word file plus a summary of what was filled, -what was missing, and which sources were used. +what was missing, and which sources were used — including connector names. ## Good to know - Output is a **draft** until a human reviews and approves it. +- Connector and tool results from earlier in the conversation are valid sources; the agent should not re-fetch them unless they are missing. - Unsupported statements are marked for review, not presented as fact. - The template is not treated as a knowledge source unless you say so. - Sections are not added or removed unless you explicitly ask. diff --git a/submissions/word-document-generator-from-template/SKILL.md b/submissions/word-document-generator-from-template/SKILL.md index 27c9bad0..d6737c88 100644 --- a/submissions/word-document-generator-from-template/SKILL.md +++ b/submissions/word-document-generator-from-template/SKILL.md @@ -1,6 +1,6 @@ --- name: word-document-generator-from-template -description: Generates a complete Word document by using an uploaded Word template and approved knowledge sources. Use when a user asks to create, draft, or compile a document from a template. +description: Generates a complete Word document by using an uploaded Word template plus user input, approved knowledge sources, and information retrieved from prior tool or connector calls. Use when a user asks to create, draft, or compile a document from a template. --- # Word Document Generator from Template @@ -10,8 +10,9 @@ Generate a complete Microsoft Word document using: - a Word template supplied at runtime; - information provided by the user; -- approved agent knowledge sources; and -- files supplied with the request. +- approved agent knowledge sources; +- files supplied with the request; and +- information retrieved from prior tool or connector calls in the same conversation (for example Dataverse, SharePoint, CRM, Azure Maps, or any Copilot Studio action). The uploaded template controls the document structure, formatting, headings, tables, headers, footers, and branding. @@ -23,7 +24,8 @@ Before generating the document, identify: - the document title and purpose; - the intended audience; - any user-provided requirements; -- the approved knowledge sources to use; and +- the approved knowledge sources to use; +- any relevant results from prior tool or connector calls; and - the required output filename. If required information is unavailable, use: @@ -42,13 +44,13 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in - repeating content areas; - headers and footers; and - required document metadata. -3. Retrieve relevant information from the approved knowledge sources. +3. Retrieve relevant information from approved knowledge sources, user-supplied files, and prior tool or connector results already available in the conversation. Prefer connector-returned facts (records, dates, owners, IDs) over restating them from memory. 4. Generate content for each document section separately. 5. Create a structured JSON object matching the template fields. 6. Validate that: - required sections are present; - required fields have values; - - generated statements are supported by approved sources; + - generated statements are supported by approved knowledge, user files, or prior tool/connector results; - repeating items are represented as arrays; and - missing information is clearly identified. 7. Populate the uploaded Word template using the validated JSON. @@ -72,7 +74,8 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in ## Generation rules -- Use only information from approved knowledge sources or user-supplied files. +- Use only information from approved knowledge sources, user-supplied files, or prior tool/connector results. Do not invent facts that those sources do not contain. +- Treat prior tool and connector outputs as approved sources. Record the tool or connector name in `source_ids` (for example `Dataverse:accounts`, `SharePoint:policy-library`). - Generate long documents section by section rather than in one response. - Keep the structured JSON as the intermediate source of truth. - Use clear, professional, organization-appropriate language. @@ -132,7 +135,13 @@ Example: "sources": [ { "source_id": "SRC-001", - "title": "Approved source document" + "title": "Approved source document", + "type": "knowledge" + }, + { + "source_id": "SRC-003", + "title": "Dataverse — Account records", + "type": "connector" } ] } @@ -141,4 +150,4 @@ Adapt the JSON fields to the actual placeholders and structure found in the runt ## Quality and safety -The generated document is a draft until reviewed and approved. If a statement cannot be supported by an approved source, do not present it as fact. Mark it for human review. +The generated document is a draft until reviewed and approved. If a statement cannot be supported by approved knowledge, a user-supplied file, or a prior tool/connector result, do not present it as fact. Mark it for human review. diff --git a/submissions/word-document-generator-from-template/metadata.json b/submissions/word-document-generator-from-template/metadata.json index 6a97eb0a..9ed31ab5 100644 --- a/submissions/word-document-generator-from-template/metadata.json +++ b/submissions/word-document-generator-from-template/metadata.json @@ -1,8 +1,8 @@ { "name": "Word Document Generator from Template", - "description": "Fill an uploaded Word template from approved knowledge sources and user files. Preserves branding, styles, and layout; never invents facts; returns a new DOCX plus a short generation summary.", + "description": "Fill an uploaded Word template from approved knowledge, user files, and prior tool or connector results. Preserves branding, styles, and layout; never invents facts; returns a new DOCX plus a short generation summary.", "platforms": ["Copilot Studio"], - "tags": ["word", "documents", "templates", "docx", "knowledge", "drafting", "compliance"], + "tags": ["word", "documents", "templates", "docx", "knowledge", "connectors", "drafting", "compliance"], "author": "Nazish Qasim", "authorUrl": "https://github.com/nazishqassim", "version": "1.0.0", From 2f0b127d43072774c7d3724bb9ec61316366dc6f Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Fri, 14 Aug 2026 09:24:12 +1000 Subject: [PATCH 3/8] Update Word Document Generator skill to clarify template sourcing and usage - Enhanced the skill description to specify that the Word template can be uploaded or retrieved from SharePoint, OneDrive, or other connectors. - Revised README to detail the template sourcing process and emphasize that the original template is never overwritten. - Adjusted metadata to reflect the updated description and broadened the tags to include SharePoint and OneDrive. - Improved instructions for locating and using the Word template during document generation. --- .../README.md | 34 ++++++++-------- .../SKILL.md | 39 +++++++++++++------ .../metadata.json | 4 +- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md index 4fcac1a5..7d88c593 100644 --- a/submissions/word-document-generator-from-template/README.md +++ b/submissions/word-document-generator-from-template/README.md @@ -1,13 +1,11 @@ # Word Document Generator from Template -Fill an uploaded Microsoft Word template using information from the user, -approved agent knowledge sources, and **results from prior tool or connector -calls** (Dataverse, SharePoint, CRM, and other Copilot Studio actions). The -template keeps control of structure, branding, styles, tables, headers, and -footers. The skill writes a **new** DOCX — it never overwrites the original -template. - -Instruction-only — no scripts or extra packages. +Fill a Microsoft Word template supplied at runtime — **uploaded**, or retrieved +from **SharePoint**, **OneDrive**, or another connector — using user input, +approved agent **knowledge sources**, and **results from prior tool or connector +calls**. The template keeps control of structure, branding, styles, tables, +headers, and footers. The skill writes a **new** DOCX — it never overwrites the +original template. ## When to use it @@ -26,7 +24,7 @@ Provide: | Input | Why it matters | | --- | --- | -| Word template (`.docx`) | Controls layout, placeholders, and branding | +| Word template (`.docx`) — **required** | Controls layout, placeholders, and branding. May be **uploaded**, or retrieved from **SharePoint**, **OneDrive**, or another connector | | Document title and purpose | Sets the draft intent | | Intended audience | Tones the language | | Requirements | Anything the template must cover | @@ -40,7 +38,7 @@ agent writes `Not specified in approved sources` instead of inventing it. ## How it works -1. Finds the uploaded Word template at runtime. +1. Finds the Word template at runtime — from the upload, SharePoint, OneDrive, or the named connector. 2. Inspects placeholders, sections, tables, headers, and footers. 3. Pulls facts from approved knowledge, user-supplied files, and prior tool or connector results. 4. Builds a structured JSON object that matches the template fields. @@ -50,13 +48,15 @@ agent writes `Not specified in approved sources` instead of inventing it. ## Example requests -> Use the attached Information Security Policy template. Draft version 0.1 for -> internal staff. Pull content only from our approved security knowledge. -> Save as `Information-Security-Policy-v0.1.docx`. +> Use the Information Security Policy template in SharePoint +> (`Policies/Templates/InfoSec-Policy.docx`). Draft version 0.1 for internal +> staff. Pull content from our approved security knowledge. Save as +> `Information-Security-Policy-v0.1.docx`. -> Get the open accounts from Dataverse, then fill the attached status-report -> template. Use those records for the table and our knowledge base for the -> narrative. Save as `Q3-Account-Status.docx`. +> Get the open accounts from Dataverse, then fill the status-report template +> in my OneDrive (`Templates/Q3-Status-Report.docx`). Use those records for +> the table and our knowledge base for the narrative. Save as +> `Q3-Account-Status.docx`. The agent returns the completed Word file plus a summary of what was filled, what was missing, and which sources were used — including connector names. @@ -66,7 +66,9 @@ what was missing, and which sources were used — including connector names. - Output is a **draft** until a human reviews and approves it. - Connector and tool results from earlier in the conversation are valid sources; the agent should not re-fetch them unless they are missing. - Unsupported statements are marked for review, not presented as fact. +- The template is a prerequisite. Attach it, or point the agent at SharePoint, OneDrive, or another connector that can fetch the `.docx`. - The template is not treated as a knowledge source unless you say so. - Sections are not added or removed unless you explicitly ask. +- The original template in SharePoint, OneDrive, or the upload is never overwritten. - If no `.docx` template is available, generation stops with: `The required Word template was not supplied or could not be accessed.` diff --git a/submissions/word-document-generator-from-template/SKILL.md b/submissions/word-document-generator-from-template/SKILL.md index d6737c88..004c857b 100644 --- a/submissions/word-document-generator-from-template/SKILL.md +++ b/submissions/word-document-generator-from-template/SKILL.md @@ -1,6 +1,6 @@ --- name: word-document-generator-from-template -description: Generates a complete Word document by using an uploaded Word template plus user input, approved knowledge sources, and information retrieved from prior tool or connector calls. Use when a user asks to create, draft, or compile a document from a template. +description: Generates a complete Word document from a Word template supplied at runtime (uploaded, or retrieved from SharePoint, OneDrive, or another connector) plus user input, approved knowledge sources, and prior tool or connector results. Use when a user asks to create, draft, or compile a document from a template. --- # Word Document Generator from Template @@ -8,19 +8,19 @@ description: Generates a complete Word document by using an uploaded Word templa Generate a complete Microsoft Word document using: -- a Word template supplied at runtime; +- a Word template supplied at runtime (uploaded by the user, or retrieved from SharePoint, OneDrive, or another connector); - information provided by the user; - approved agent knowledge sources; - files supplied with the request; and - information retrieved from prior tool or connector calls in the same conversation (for example Dataverse, SharePoint, CRM, Azure Maps, or any Copilot Studio action). -The uploaded template controls the document structure, formatting, headings, tables, headers, footers, and branding. +The runtime template controls the document structure, formatting, headings, tables, headers, footers, and branding. ## Required inputs Before generating the document, identify: -- the Word template to use; +- the Word template to use, and where it comes from (upload, SharePoint, OneDrive, or another location); - the document title and purpose; - the intended audience; - any user-provided requirements; @@ -36,7 +36,12 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in ## Instructions -1. Locate the Word template supplied at runtime. +1. Locate the Word template supplied at runtime. Obtain it from whichever source the user identified: + - a file uploaded with the request; + - SharePoint (library, folder, or site); + - OneDrive; or + - another connector or prior tool result that returns a `.docx` file. + If the template is not already in the working directory, retrieve it with the matching connector or tool before continuing. 2. Inspect the template to identify: - placeholders; - headings and sections; @@ -53,7 +58,7 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in - generated statements are supported by approved knowledge, user files, or prior tool/connector results; - repeating items are represented as arrays; and - missing information is clearly identified. -7. Populate the uploaded Word template using the validated JSON. +7. Populate the runtime Word template using the validated JSON. 8. Preserve the template's: - styles; - layout; @@ -80,24 +85,36 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in - Keep the structured JSON as the intermediate source of truth. - Use clear, professional, organization-appropriate language. - Preserve mandatory wording found in approved sources. -- Do not treat the uploaded template as a knowledge source unless instructed. +- Do not treat the template file as a knowledge source unless instructed. - Do not add new sections unless required to complete the template. - Do not remove sections from the template without an explicit instruction. - Record the sources used for each major section when source information is available. ## Template handling -The template will be uploaded or retrieved at runtime. +A Word template (`.docx`) is a **prerequisite**. It is supplied at runtime from one of: -Search the runtime working directory for the supplied `.docx` file. If more than one Word template is available, select the template identified in the user request. +- a file **uploaded** with the request; +- **SharePoint** (document library, folder, or site); +- **OneDrive**; or +- another connector or prior tool call that returns a Word file. -If the required template cannot be found, stop document generation and report: +Resolve the template in this order: + +1. Use the template the user named (filename, SharePoint/OneDrive path, or library item). +2. If it is already in the runtime working directory, use that `.docx`. +3. If it is not local, retrieve it from SharePoint, OneDrive, or the identified connector. +4. If more than one Word file is available, select the one identified in the user request. + +Never overwrite the original template in SharePoint, OneDrive, or local storage. Always save a **new** DOCX. + +If the required template cannot be found or retrieved, stop document generation and report: 'The required Word template was not supplied or could not be accessed.' ## Structured JSON -Create JSON that reflects the uploaded template. Use: +Create JSON that reflects the runtime template. Use: - strings for individual fields; - objects for document sections; diff --git a/submissions/word-document-generator-from-template/metadata.json b/submissions/word-document-generator-from-template/metadata.json index 9ed31ab5..584990ae 100644 --- a/submissions/word-document-generator-from-template/metadata.json +++ b/submissions/word-document-generator-from-template/metadata.json @@ -1,8 +1,8 @@ { "name": "Word Document Generator from Template", - "description": "Fill an uploaded Word template from approved knowledge, user files, and prior tool or connector results. Preserves branding, styles, and layout; never invents facts; returns a new DOCX plus a short generation summary.", + "description": "Fill a Word template supplied at runtime (upload, SharePoint, OneDrive, or another connector) from approved knowledge, user files, and prior tool results. Preserves branding and layout; never invents facts; returns a new DOCX plus a short generation summary.", "platforms": ["Copilot Studio"], - "tags": ["word", "documents", "templates", "docx", "knowledge", "connectors", "drafting", "compliance"], + "tags": ["word", "documents", "templates", "docx", "sharepoint", "onedrive", "knowledge", "connectors", "drafting", "compliance"], "author": "Nazish Qasim", "authorUrl": "https://github.com/nazishqassim", "version": "1.0.0", From c16b962edbce3f79b34fba68df046bf62a627c9b Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Fri, 14 Aug 2026 22:34:16 +1000 Subject: [PATCH 4/8] Update Word Document Generator to reflect new Leave Policy template - Revised README to replace the Information Security Policy template with the Leave Policy template, including updated document title and owner. - Adjusted SKILL.md to reflect changes in document structure, including new leave types and updated source document titles for HR context. - Enhanced examples to align with the new policy focus and ensure clarity in usage. --- .../README.md | 8 ++++---- .../SKILL.md | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md index 7d88c593..7d03ff8b 100644 --- a/submissions/word-document-generator-from-template/README.md +++ b/submissions/word-document-generator-from-template/README.md @@ -48,10 +48,10 @@ agent writes `Not specified in approved sources` instead of inventing it. ## Example requests -> Use the Information Security Policy template in SharePoint -> (`Policies/Templates/InfoSec-Policy.docx`). Draft version 0.1 for internal -> staff. Pull content from our approved security knowledge. Save as -> `Information-Security-Policy-v0.1.docx`. +> Use the Leave Policy template in SharePoint +> (`Policies/Templates/Leave-Policy.docx`). Draft version 0.1 for internal +> staff. Pull content from our approved HR knowledge. Save as +> `Leave-Policy-v0.1.docx`. > Get the open accounts from Dataverse, then fill the status-report template > in my OneDrive (`Templates/Q3-Status-Report.docx`). Use those records for diff --git a/submissions/word-document-generator-from-template/SKILL.md b/submissions/word-document-generator-from-template/SKILL.md index 004c857b..9a5c028f 100644 --- a/submissions/word-document-generator-from-template/SKILL.md +++ b/submissions/word-document-generator-from-template/SKILL.md @@ -125,8 +125,8 @@ Example: { "document": { - "title": "Information Security Policy", - "owner": "Information Security", + "title": "Leave Policy", + "owner": "Human Resources", "version": "0.1", "status": "Draft" }, @@ -140,24 +140,25 @@ Example: "source_ids": ["SRC-001", "SRC-002"] } }, - "requirements": [ + "leave_types": [ { - "id": "REQ-001", - "requirement": "Generated requirement", - "owner": "System owner", - "evidence": "Approval record", + "id": "LV-001", + "leave_type": "Annual leave", + "entitlement": "Generated entitlement", + "owner": "Human Resources", + "evidence": "HR policy handbook", "source_ids": ["SRC-002"] } ], "sources": [ { "source_id": "SRC-001", - "title": "Approved source document", + "title": "Approved HR source document", "type": "knowledge" }, { "source_id": "SRC-003", - "title": "Dataverse — Account records", + "title": "Dataverse — Employee leave records", "type": "connector" } ] From 2bf2dd0f04cf8d3e987a9085eb3f249668c49551 Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Fri, 14 Aug 2026 22:44:24 +1000 Subject: [PATCH 5/8] Enhance README for Word Document Generator with ideal template structure - Added a new section detailing the ideal structure for Word templates, including guidelines for using styles and placeholders. - Included a "Do's and Don'ts" list to improve template design and ensure compatibility with the document generation process. - Provided an example Leave Policy template to illustrate the recommended format and usage of placeholders. --- .../README.md | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md index 7d03ff8b..9aab9f57 100644 --- a/submissions/word-document-generator-from-template/README.md +++ b/submissions/word-document-generator-from-template/README.md @@ -1,7 +1,7 @@ # Word Document Generator from Template Fill a Microsoft Word template supplied at runtime — **uploaded**, or retrieved -from **SharePoint**, **OneDrive**, or another connector — using user input, +from **SharePoint**, **OneDrive**, or another connector — using **user input**, approved agent **knowledge sources**, and **results from prior tool or connector calls**. The template keeps control of structure, branding, styles, tables, headers, and footers. The skill writes a **new** DOCX — it never overwrites the @@ -36,6 +36,67 @@ Approved sources include knowledge, uploaded files, **and** data returned by upstream tools or connectors. If a required fact is not in those sources, the agent writes `Not specified in approved sources` instead of inventing it. +## Ideal Word template structure + +Design the `.docx` so the agent can map sections and placeholders to JSON +without guessing. Use **Word styles** (Heading 1, Heading 2, Normal) and +clear `{{placeholders}}` — not dummy paragraphs of finished policy text. + +**Do** + +- Put branding, page numbers, and classification in the **header / footer**. +- Use **Heading 1** for every major section the finished document must keep. +- Use a **one-row sample table** for repeating items (leave types, owners, dates). +- Name placeholders to match fields: `{{document.title}}`, `{{sections.purpose}}`. +- Leave body cells short: `{{sections.purpose}}` or `[Insert purpose — from approved sources]`. + +**Don’t** + +- Bury real policy wording in the template (it is not a knowledge source). +- Use floating text boxes or images that hide placeholders. +- Skip headings and rely on bold paragraphs — the agent may miss sections. + +### Example — Leave Policy template + +What the Word file should look like before generation: + +**Header:** `Contoso | Internal | {{document.title}}` + +**Title (Heading 1):** `{{document.title}}` + +**Document control (Heading 2)** — one metadata table: + +| Field | Placeholder | +| --- | --- | +| Owner | `{{document.owner}}` | +| Version | `{{document.version}}` | +| Status | `{{document.status}}` | +| Audience | `{{document.audience}}` | + +**Body sections** — each is Heading 1 with a single placeholder underneath: + +| Heading 1 | Body | +| --- | --- | +| 1. Executive summary | `{{sections.executive_summary}}` | +| 2. Purpose | `{{sections.purpose}}` | +| 3. Scope | `{{sections.scope}}` | +| 4. Leave types | Repeating table (below) | +| 5. Responsibilities | `{{sections.responsibilities}}` | +| 6. Related documents | `{{sections.related_documents}}` | + +**Leave types table** — keep the header row; leave **one sample data row** for the agent to clone: + +| Leave type | Entitlement | Owner | Evidence | +| --- | --- | --- | --- | +| `{{leave_type}}` | `{{leave_entitlement}}` | `{{leave_owner}}` | `{{leave_evidence}}` | + +**Footer:** `{{document.version}} | Page X of Y | Draft` + +The agent fills those placeholders from approved sources, **repeats the +leave-types row** for each item in the JSON array, and leaves unused +fields as `Not specified in approved sources`. Styles, header, footer, +and table formatting stay as they are in the template. + ## How it works 1. Finds the Word template at runtime — from the upload, SharePoint, OneDrive, or the named connector. From 2a89b9696e867a653dfc01f7522b81aeea1e0570 Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Fri, 14 Aug 2026 23:06:59 +1000 Subject: [PATCH 6/8] Update Word Document Generator skill to enhance document type flexibility and template usage - Revised the skill description to clarify that the generator can create various document types, including policy, procedure, report, and paper. - Updated README to reflect changes in the template structure and provide clearer guidelines for document generation. - Enhanced metadata description and tags to better represent the skill's capabilities and supported document types. - Improved instructions for identifying and using the Word template during the generation process. --- .../README.md | 115 ++++++++++-------- .../SKILL.md | 71 +++++------ .../metadata.json | 4 +- 3 files changed, 97 insertions(+), 93 deletions(-) diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md index 9aab9f57..ef6d61cd 100644 --- a/submissions/word-document-generator-from-template/README.md +++ b/submissions/word-document-generator-from-template/README.md @@ -3,32 +3,33 @@ Fill a Microsoft Word template supplied at runtime — **uploaded**, or retrieved from **SharePoint**, **OneDrive**, or another connector — using **user input**, approved agent **knowledge sources**, and **results from prior tool or connector -calls**. The template keeps control of structure, branding, styles, tables, -headers, and footers. The skill writes a **new** DOCX — it never overwrites the -original template. +calls**. Works for any document the template defines: **policy, procedure, +report, paper, briefing, SOP, statement of work**, or similar. + +The template keeps control of structure, branding, styles, tables, headers, and +footers. The skill writes a **new** DOCX — it never overwrites the original. ## When to use it -Ask the agent to create, draft, or compile a document from a template, for -example: +| Document kind | Typical template | +| --- | --- | +| Policy | Corporate policy shell (purpose, scope, rules, related docs) | +| Procedure / SOP | Numbered steps, roles, inputs/outputs | +| Report / briefing | Summary, findings table, recommendations | +| Paper | Title, abstract, body headings, references | +| Status pack | Narrative plus rows from Dataverse, SharePoint, or another connector | -- a leave policy from the corporate policy template; -- a statement of work from a standard SOW template; -- a report, briefing, or paper from a branded Word file; -- a procedure or SOP that must keep the official heading structure; -- a status report whose tables come from Dataverse, SharePoint, or another connector called earlier in the conversation. +Ask the agent to create, draft, or compile the document from that template. ## Before you start -Provide: - | Input | Why it matters | | --- | --- | | Word template (`.docx`) — **required** | Controls layout, placeholders, and branding. May be **uploaded**, or retrieved from **SharePoint**, **OneDrive**, or another connector | -| Document title and purpose | Sets the draft intent | +| Document type, title, and purpose | Sets what is being drafted | | Intended audience | Tones the language | | Requirements | Anything the template must cover | -| Approved knowledge sources | Policies, manuals, and other grounded content | +| Approved knowledge sources | Grounded content | | Prior tool / connector results | Records, lists, and fields already retrieved this conversation | | Output filename | Name of the new DOCX | @@ -38,86 +39,94 @@ agent writes `Not specified in approved sources` instead of inventing it. ## Ideal Word template structure -Design the `.docx` so the agent can map sections and placeholders to JSON -without guessing. Use **Word styles** (Heading 1, Heading 2, Normal) and -clear `{{placeholders}}` — not dummy paragraphs of finished policy text. +The same pattern works for every document type. Use **Word styles** (Heading 1, +Heading 2, Normal) and `{{placeholders}}` — not finished body text. **Do** - Put branding, page numbers, and classification in the **header / footer**. - Use **Heading 1** for every major section the finished document must keep. -- Use a **one-row sample table** for repeating items (leave types, owners, dates). -- Name placeholders to match fields: `{{document.title}}`, `{{sections.purpose}}`. -- Leave body cells short: `{{sections.purpose}}` or `[Insert purpose — from approved sources]`. +- Use a **one-row sample table** for anything that repeats (steps, findings, leave types, owners). +- Name placeholders after the field: `{{document.title}}`, `{{sections.}}`. +- Keep body cells short: `{{sections.purpose}}` or `[Insert from approved sources]`. **Don’t** -- Bury real policy wording in the template (it is not a knowledge source). +- Bury finished wording in the template (it is not a knowledge source). - Use floating text boxes or images that hide placeholders. - Skip headings and rely on bold paragraphs — the agent may miss sections. -### Example — Leave Policy template +### Generic skeleton -What the Word file should look like before generation: - -**Header:** `Contoso | Internal | {{document.title}}` +**Header:** `Organisation | Classification | {{document.title}}` **Title (Heading 1):** `{{document.title}}` -**Document control (Heading 2)** — one metadata table: +**Document control (Heading 2)** | Field | Placeholder | | --- | --- | +| Type | `{{document.type}}` | | Owner | `{{document.owner}}` | | Version | `{{document.version}}` | | Status | `{{document.status}}` | | Audience | `{{document.audience}}` | -**Body sections** — each is Heading 1 with a single placeholder underneath: +**Body** — one Heading 1 per section, placeholder underneath. Name sections +after the template, for example: -| Heading 1 | Body | +| Kind | Typical Heading 1s | | --- | --- | -| 1. Executive summary | `{{sections.executive_summary}}` | -| 2. Purpose | `{{sections.purpose}}` | -| 3. Scope | `{{sections.scope}}` | -| 4. Leave types | Repeating table (below) | -| 5. Responsibilities | `{{sections.responsibilities}}` | -| 6. Related documents | `{{sections.related_documents}}` | +| Policy | Purpose, Scope, Policy statements, Responsibilities, Related documents | +| Procedure | Purpose, Scope, Roles, Procedure steps, Exceptions | +| Report | Executive summary, Findings, Analysis, Recommendations | +| Paper | Abstract, Introduction, Discussion, Conclusion, References | + +`{{sections.purpose}}`, `{{sections.scope}}`, `{{sections.findings}}`, and so on. -**Leave types table** — keep the header row; leave **one sample data row** for the agent to clone: +**Repeating table** — keep the header row; leave **one sample data row** to clone: -| Leave type | Entitlement | Owner | Evidence | -| --- | --- | --- | --- | -| `{{leave_type}}` | `{{leave_entitlement}}` | `{{leave_owner}}` | `{{leave_evidence}}` | +| Column A | Column B | Column C | +| --- | --- | --- | +| `{{item.col_a}}` | `{{item.col_b}}` | `{{item.col_c}}` | -**Footer:** `{{document.version}} | Page X of Y | Draft` +Rename columns to match the document (`Step` / `Owner` / `System`, or +`Finding` / `Impact` / `Action`, or `Leave type` / `Entitlement` / `Owner`). -The agent fills those placeholders from approved sources, **repeats the -leave-types row** for each item in the JSON array, and leaves unused -fields as `Not specified in approved sources`. Styles, header, footer, -and table formatting stay as they are in the template. +**Footer:** `{{document.version}} | Page X of Y | {{document.status}}` + +The agent fills placeholders from approved sources, **repeats the sample row** +for each JSON array item, and leaves gaps as `Not specified in approved +sources`. Styles, header, footer, and table formatting stay as in the template. + +### Example mapping — Leave Policy + +A leave policy is only one use of the same skeleton: Heading 1s become Purpose, +Scope, Leave types, Responsibilities; the repeating table columns become +`{{leave_type}}`, `{{leave_entitlement}}`, `{{leave_owner}}`, `{{leave_evidence}}`. ## How it works 1. Finds the Word template at runtime — from the upload, SharePoint, OneDrive, or the named connector. -2. Inspects placeholders, sections, tables, headers, and footers. +2. Inspects document type, placeholders, sections, tables, headers, and footers. 3. Pulls facts from approved knowledge, user-supplied files, and prior tool or connector results. -4. Builds a structured JSON object that matches the template fields. +4. Builds JSON that matches **this** template's fields. 5. Validates required sections, source-backed statements, and repeating rows. 6. Populates the template while preserving styles and layout. 7. Saves a new DOCX and returns a short generation summary. ## Example requests -> Use the Leave Policy template in SharePoint -> (`Policies/Templates/Leave-Policy.docx`). Draft version 0.1 for internal -> staff. Pull content from our approved HR knowledge. Save as -> `Leave-Policy-v0.1.docx`. +> Use the Leave Policy template in SharePoint (`Policies/Templates/Leave-Policy.docx`). +> Draft version 0.1 for internal staff from approved HR knowledge. +> Save as `Leave-Policy-v0.1.docx`. + +> Fill the incident-response **procedure** template in OneDrive. +> Use the approved ops playbook for the steps. Save as `IR-Procedure-v2.docx`. -> Get the open accounts from Dataverse, then fill the status-report template -> in my OneDrive (`Templates/Q3-Status-Report.docx`). Use those records for -> the table and our knowledge base for the narrative. Save as -> `Q3-Account-Status.docx`. +> Get this quarter's accounts from Dataverse, then fill the **status report** +> template. Connector rows go in the findings table; knowledge base for the narrative. +> Save as `Q3-Account-Status.docx`. The agent returns the completed Word file plus a summary of what was filled, what was missing, and which sources were used — including connector names. diff --git a/submissions/word-document-generator-from-template/SKILL.md b/submissions/word-document-generator-from-template/SKILL.md index 9a5c028f..7bbf2737 100644 --- a/submissions/word-document-generator-from-template/SKILL.md +++ b/submissions/word-document-generator-from-template/SKILL.md @@ -1,27 +1,28 @@ --- name: word-document-generator-from-template -description: Generates a complete Word document from a Word template supplied at runtime (uploaded, or retrieved from SharePoint, OneDrive, or another connector) plus user input, approved knowledge sources, and prior tool or connector results. Use when a user asks to create, draft, or compile a document from a template. +description: Generates a complete Word document from a Word template supplied at runtime (uploaded, or retrieved from SharePoint, OneDrive, or another connector) plus user input, approved knowledge sources, and prior tool or connector results. Use when a user asks to create, draft, or compile any document from a template — policy, procedure, report, paper, briefing, SOP, or similar. --- # Word Document Generator from Template ## Purpose -Generate a complete Microsoft Word document using: +Generate a complete Microsoft Word document of **any type the template defines** +(policy, procedure, report, paper, briefing, SOP, statement of work, or similar) using: - a Word template supplied at runtime (uploaded by the user, or retrieved from SharePoint, OneDrive, or another connector); - information provided by the user; - approved agent knowledge sources; - files supplied with the request; and -- information retrieved from prior tool or connector calls in the same conversation (for example Dataverse, SharePoint, CRM, Azure Maps, or any Copilot Studio action). +- information retrieved from prior tool or connector calls in the same conversation (for example Dataverse, SharePoint, CRM, or any Copilot Studio action). -The runtime template controls the document structure, formatting, headings, tables, headers, footers, and branding. +The runtime template controls document type, structure, formatting, headings, tables, headers, footers, and branding. Adapt sections, tables, and JSON keys to **that** template — do not assume a fixed outline. ## Required inputs Before generating the document, identify: - the Word template to use, and where it comes from (upload, SharePoint, OneDrive, or another location); -- the document title and purpose; +- the document type, title, and purpose; - the intended audience; - any user-provided requirements; - the approved knowledge sources to use; @@ -43,6 +44,7 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in - another connector or prior tool result that returns a `.docx` file. If the template is not already in the working directory, retrieve it with the matching connector or tool before continuing. 2. Inspect the template to identify: + - document type (policy, procedure, report, paper, or other); - placeholders; - headings and sections; - tables; @@ -50,10 +52,10 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in - headers and footers; and - required document metadata. 3. Retrieve relevant information from approved knowledge sources, user-supplied files, and prior tool or connector results already available in the conversation. Prefer connector-returned facts (records, dates, owners, IDs) over restating them from memory. -4. Generate content for each document section separately. -5. Create a structured JSON object matching the template fields. +4. Generate content for each document section separately, matching the template's headings. +5. Create a structured JSON object matching the template fields (not a fixed schema). 6. Validate that: - - required sections are present; + - required sections from the template are present; - required fields have values; - generated statements are supported by approved knowledge, user files, or prior tool/connector results; - repeating items are represented as arrays; and @@ -79,11 +81,12 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in ## Generation rules +- Follow the uploaded template's outline. Rename JSON keys to match its headings and placeholders. - Use only information from approved knowledge sources, user-supplied files, or prior tool/connector results. Do not invent facts that those sources do not contain. - Treat prior tool and connector outputs as approved sources. Record the tool or connector name in `source_ids` (for example `Dataverse:accounts`, `SharePoint:policy-library`). - Generate long documents section by section rather than in one response. - Keep the structured JSON as the intermediate source of truth. -- Use clear, professional, organization-appropriate language. +- Use clear, professional, organization-appropriate language for the stated audience. - Preserve mandatory wording found in approved sources. - Do not treat the template file as a knowledge source unless instructed. - Do not add new sections unless required to complete the template. @@ -114,57 +117,49 @@ If the required template cannot be found or retrieved, stop document generation ## Structured JSON -Create JSON that reflects the runtime template. Use: +Create JSON that reflects the **runtime template**. Use: -- strings for individual fields; -- objects for document sections; -- arrays for repeating tables or content blocks; and -- source identifiers for traceability. +- `document` — title, type, owner, version, status, audience, and any other metadata fields on the cover or control table; +- `sections` — one object per Heading 1 / Heading 2, keyed by a slug of that heading; +- `items` (or a name taken from the table, e.g. `leave_types`, `findings`, `steps`) — arrays for repeating tables or content blocks; +- `sources` — identifiers for knowledge, files, and connectors. -Example: +Example shape (field names change to match the template): +```json { "document": { - "title": "Leave Policy", - "owner": "Human Resources", - "version": "0.1", - "status": "Draft" + "title": "{{document.title}}", + "type": "policy | procedure | report | paper | other", + "owner": "{{document.owner}}", + "version": "{{document.version}}", + "status": "{{document.status}}", + "audience": "{{document.audience}}" }, "sections": { - "executive_summary": { + "": { "content": "Generated section content", "source_ids": ["SRC-001"] - }, - "purpose": { - "content": "Generated section content", - "source_ids": ["SRC-001", "SRC-002"] } }, - "leave_types": [ + "items": [ { - "id": "LV-001", - "leave_type": "Annual leave", - "entitlement": "Generated entitlement", - "owner": "Human Resources", - "evidence": "HR policy handbook", + "col_1": "Value for first repeating-table column", + "col_2": "Value for second column", "source_ids": ["SRC-002"] } ], "sources": [ { "source_id": "SRC-001", - "title": "Approved HR source document", - "type": "knowledge" - }, - { - "source_id": "SRC-003", - "title": "Dataverse — Employee leave records", - "type": "connector" + "title": "Approved source", + "type": "knowledge | file | connector" } ] } +``` -Adapt the JSON fields to the actual placeholders and structure found in the runtime template. +A Leave Policy template would map `items` to `leave_types`; a procedure would map it to `steps`; a report would map it to `findings` or connector rows. Always adapt to the actual placeholders. ## Quality and safety diff --git a/submissions/word-document-generator-from-template/metadata.json b/submissions/word-document-generator-from-template/metadata.json index 584990ae..5891192b 100644 --- a/submissions/word-document-generator-from-template/metadata.json +++ b/submissions/word-document-generator-from-template/metadata.json @@ -1,8 +1,8 @@ { "name": "Word Document Generator from Template", - "description": "Fill a Word template supplied at runtime (upload, SharePoint, OneDrive, or another connector) from approved knowledge, user files, and prior tool results. Preserves branding and layout; never invents facts; returns a new DOCX plus a short generation summary.", + "description": "Fill a Word template supplied at runtime (upload, SharePoint, OneDrive, or another connector) for any document type — policy, procedure, report, or paper. Uses approved knowledge, user files, and prior tool results. Preserves branding; never invents facts; returns a new DOCX.", "platforms": ["Copilot Studio"], - "tags": ["word", "documents", "templates", "docx", "sharepoint", "onedrive", "knowledge", "connectors", "drafting", "compliance"], + "tags": ["word", "documents", "templates", "docx", "policy", "procedure", "reports", "sharepoint", "onedrive", "knowledge", "connectors", "drafting"], "author": "Nazish Qasim", "authorUrl": "https://github.com/nazishqassim", "version": "1.0.0", From 720960cf3e5b62df88918322dd4c1b66a0f41376 Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Mon, 17 Aug 2026 22:40:46 +1000 Subject: [PATCH 7/8] Enhance Word Document Generator: Update instructions for template handling, validation, and JSON structure. Introduce deterministic engine for filling and inspecting DOCX files, ensuring preservation of live fields and branding. Update metadata and README for clarity on usage and capabilities. --- .../README.md | 76 +- .../SKILL.md | 138 ++-- .../assets/sample-data.json | 41 + .../metadata.json | 4 +- .../references/placeholder-contract.md | 132 ++++ .../scripts/build_sample_template.py | 183 +++++ .../scripts/docx_template.py | 705 ++++++++++++++++++ .../scripts/test_docx_template.py | 279 +++++++ 8 files changed, 1489 insertions(+), 69 deletions(-) create mode 100644 submissions/word-document-generator-from-template/assets/sample-data.json create mode 100644 submissions/word-document-generator-from-template/references/placeholder-contract.md create mode 100644 submissions/word-document-generator-from-template/scripts/build_sample_template.py create mode 100644 submissions/word-document-generator-from-template/scripts/docx_template.py create mode 100644 submissions/word-document-generator-from-template/scripts/test_docx_template.py diff --git a/submissions/word-document-generator-from-template/README.md b/submissions/word-document-generator-from-template/README.md index ef6d61cd..d7c581b4 100644 --- a/submissions/word-document-generator-from-template/README.md +++ b/submissions/word-document-generator-from-template/README.md @@ -7,7 +7,9 @@ calls**. Works for any document the template defines: **policy, procedure, report, paper, briefing, SOP, statement of work**, or similar. The template keeps control of structure, branding, styles, tables, headers, and -footers. The skill writes a **new** DOCX — it never overwrites the original. +footers. A deterministic OOXML engine handles split Word runs, repeating table +rows, and validation while preserving live PAGE / NUMPAGES fields. The skill +writes a **new** DOCX — it never overwrites the original. ## When to use it @@ -40,13 +42,19 @@ agent writes `Not specified in approved sources` instead of inventing it. ## Ideal Word template structure The same pattern works for every document type. Use **Word styles** (Heading 1, -Heading 2, Normal) and `{{placeholders}}` — not finished body text. +Heading 2, Normal) and deterministic `{{placeholders}}` — not finished body +text. The engine supports: + +- scalar paths: `{{document.title}}`, `{{sections.purpose}}`; +- repeating rows: `{{findings[].finding}}`, `{{findings[].owner}}`; +- placeholders that Word splits across multiple formatting runs; +- body, table, header, and footer text. **Do** - Put branding, page numbers, and classification in the **header / footer**. - Use **Heading 1** for every major section the finished document must keep. -- Use a **one-row sample table** for anything that repeats (steps, findings, leave types, owners). +- Use a **one-row sample table** for anything that repeats (steps, findings, leave types, owners), with the array name followed by `[]`. - Name placeholders after the field: `{{document.title}}`, `{{sections.}}`. - Keep body cells short: `{{sections.purpose}}` or `[Insert from approved sources]`. @@ -88,13 +96,18 @@ after the template, for example: | Column A | Column B | Column C | | --- | --- | --- | -| `{{item.col_a}}` | `{{item.col_b}}` | `{{item.col_c}}` | +| `{{items[].col_a}}` | `{{items[].col_b}}` | `{{items[].col_c}}` | Rename columns to match the document (`Step` / `Owner` / `System`, or `Finding` / `Impact` / `Action`, or `Leave type` / `Entitlement` / `Owner`). +Use one array per sample row. **Footer:** `{{document.version}} | Page X of Y | {{document.status}}` +Insert Page X of Y with Word's live PAGE and NUMPAGES fields, not typed numbers. +The engine changes only the placeholders and verifies those field instructions +remain intact. + The agent fills placeholders from approved sources, **repeats the sample row** for each JSON array item, and leaves gaps as `Not specified in approved sources`. Styles, header, footer, and table formatting stay as in the template. @@ -103,17 +116,57 @@ sources`. Styles, header, footer, and table formatting stay as in the template. A leave policy is only one use of the same skeleton: Heading 1s become Purpose, Scope, Leave types, Responsibilities; the repeating table columns become -`{{leave_type}}`, `{{leave_entitlement}}`, `{{leave_owner}}`, `{{leave_evidence}}`. +`{{leave_types[].leave_type}}`, `{{leave_types[].entitlement}}`, +`{{leave_types[].owner}}`, `{{leave_types[].evidence}}`. ## How it works 1. Finds the Word template at runtime — from the upload, SharePoint, OneDrive, or the named connector. -2. Inspects document type, placeholders, sections, tables, headers, and footers. +2. Runs deterministic inspection to discover exact placeholders, repeating arrays, and Word fields. 3. Pulls facts from approved knowledge, user-supplied files, and prior tool or connector results. 4. Builds JSON that matches **this** template's fields. -5. Validates required sections, source-backed statements, and repeating rows. -6. Populates the template while preserving styles and layout. -7. Saves a new DOCX and returns a short generation summary. +5. Fills a new DOCX with split-run and repeating-row support. +6. Validates package integrity, unresolved placeholders, and live Word fields. +7. Returns the new DOCX and a machine-generated summary. + +## Quick test + +The bundled report template intentionally contains split-run placeholders, a +repeating findings row, branding, two sections, and live PAGE / NUMPAGES fields. + +```bash +# 1. Discover the template contract +python scripts/docx_template.py inspect assets/sample-template.docx \ + --output sample-manifest.json + +# 2. Fill a new file +python scripts/docx_template.py fill \ + assets/sample-template.docx assets/sample-data.json sample-output.docx \ + --summary sample-summary.json + +# 3. Verify there are no raw tokens and live fields survived +python scripts/docx_template.py validate sample-output.docx \ + --template assets/sample-template.docx --output sample-validation.json +``` + +Expected: each command exits `0`, the findings table has three data rows, no +`{{...}}` remains, and validation reports +`"field_signature_preserved": true`. + +For the full grammar and limits, see +[`references/placeholder-contract.md`](references/placeholder-contract.md). + +### Bundled files + +| File | Purpose | +| --- | --- | +| `scripts/docx_template.py` | Production inspect / fill / validate engine | +| `assets/sample-template.docx` | Realistic report template with split runs and live fields | +| `assets/sample-data.json` | Template-shaped example data | +| `assets/sample-template.manifest.json` | Expected inspection result | +| `references/placeholder-contract.md` | Exact grammar, supported scope, and limits | +| `scripts/test_docx_template.py` | Automated regression suite | +| `scripts/build_sample_template.py` | Rebuild the sample template | ## Example requests @@ -140,5 +193,10 @@ what was missing, and which sources were used — including connector names. - The template is not treated as a knowledge source unless you say so. - Sections are not added or removed unless you explicitly ask. - The original template in SharePoint, OneDrive, or the upload is never overwritten. +- Supported replacement content is plain text (including line breaks). Rich + HTML/Markdown, nested repeating arrays, and placeholders spanning paragraphs + are intentionally rejected or out of scope. +- Filling fails loudly on malformed tokens, invalid JSON shapes, remaining + placeholders, corrupt DOCX packages, or changed Word field instructions. - If no `.docx` template is available, generation stops with: `The required Word template was not supplied or could not be accessed.` diff --git a/submissions/word-document-generator-from-template/SKILL.md b/submissions/word-document-generator-from-template/SKILL.md index 7bbf2737..aeb420ca 100644 --- a/submissions/word-document-generator-from-template/SKILL.md +++ b/submissions/word-document-generator-from-template/SKILL.md @@ -15,7 +15,10 @@ Generate a complete Microsoft Word document of **any type the template defines** - files supplied with the request; and - information retrieved from prior tool or connector calls in the same conversation (for example Dataverse, SharePoint, CRM, or any Copilot Studio action). -The runtime template controls document type, structure, formatting, headings, tables, headers, footers, and branding. Adapt sections, tables, and JSON keys to **that** template — do not assume a fixed outline. +The runtime template controls document type, structure, formatting, headings, +tables, headers, footers, and branding. Adapt JSON keys to **that** template — +do not assume a fixed outline. Use the bundled deterministic engine for DOCX +inspection, filling, and validation; do not implement ad-hoc run replacement. ## Required inputs @@ -37,51 +40,50 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in ## Instructions -1. Locate the Word template supplied at runtime. Obtain it from whichever source the user identified: - - a file uploaded with the request; - - SharePoint (library, folder, or site); - - OneDrive; or - - another connector or prior tool result that returns a `.docx` file. - If the template is not already in the working directory, retrieve it with the matching connector or tool before continuing. -2. Inspect the template to identify: - - document type (policy, procedure, report, paper, or other); - - placeholders; - - headings and sections; - - tables; - - repeating content areas; - - headers and footers; and - - required document metadata. -3. Retrieve relevant information from approved knowledge sources, user-supplied files, and prior tool or connector results already available in the conversation. Prefer connector-returned facts (records, dates, owners, IDs) over restating them from memory. -4. Generate content for each document section separately, matching the template's headings. -5. Create a structured JSON object matching the template fields (not a fixed schema). -6. Validate that: - - required sections from the template are present; - - required fields have values; - - generated statements are supported by approved knowledge, user files, or prior tool/connector results; - - repeating items are represented as arrays; and - - missing information is clearly identified. -7. Populate the runtime Word template using the validated JSON. -8. Preserve the template's: - - styles; - - layout; - - branding; - - section breaks; - - headers and footers; and - - table formatting. -9. Save the completed document as a new DOCX file. -10. Never overwrite the original template. -11. Check the completed document for: - - unresolved placeholders; - - missing sections; - - broken tables; - - duplicated content; - - unsupported statements; and - - formatting or pagination problems. -12. Return the completed Word document and a short generation summary. +1. Locate the runtime `.docx` template. Use the uploaded file, or retrieve the + user-identified SharePoint / OneDrive / connector item into the working + directory. Stop with the message under **Template handling** if unavailable. +2. Inspect it before writing content: + + ```bash + python scripts/docx_template.py inspect template.docx --output manifest.json + ``` + + Read the manifest's exact scalar placeholders, repeating arrays, parts, and + live Word fields. If inspection rejects the template, report the error; do + not guess at its schema. +3. Retrieve relevant information from approved knowledge, user files, and prior + tool/connector results already in the conversation. Prefer connector-returned + records, dates, owners, and IDs over restating them from memory. +4. Generate long documents section by section. Build a JSON object whose paths + exactly match the manifest. Use arrays for repeating table rows. Use + `Not specified in approved sources` for unsupported facts. +5. Validate the JSON conceptually: all required template fields are represented, + claims are grounded, and each array item supplies the expected row fields. +6. Fill a **new** file with the deterministic engine: + + ```bash + python scripts/docx_template.py fill template.docx data.json output.docx \ + --summary fill-summary.json + ``` + + Never set `output.docx` to the template path. +7. Validate package integrity, unresolved tokens, and live Word fields: + + ```bash + python scripts/docx_template.py validate output.docx \ + --template template.docx --output validation.json + ``` + + Do not return a DOCX unless both commands succeed. +8. Return the completed DOCX plus a short generation summary: output filename, + document type, sources used, filled/defaulted fields, repeated-row counts, + and validation status. ## Generation rules -- Follow the uploaded template's outline. Rename JSON keys to match its headings and placeholders. +- Follow the runtime template's outline. JSON keys must match the inspection + manifest, not a hard-coded schema. - Use only information from approved knowledge sources, user-supplied files, or prior tool/connector results. Do not invent facts that those sources do not contain. - Treat prior tool and connector outputs as approved sources. Record the tool or connector name in `source_ids` (for example `Dataverse:accounts`, `SharePoint:policy-library`). - Generate long documents section by section rather than in one response. @@ -92,6 +94,9 @@ Do not invent facts, dates, owners, approvals, obligations, or organizational in - Do not add new sections unless required to complete the template. - Do not remove sections from the template without an explicit instruction. - Record the sources used for each major section when source information is available. +- Do not replace runs manually or clear footer/header paragraphs. The script + handles split-run tokens and preserves PAGE, NUMPAGES, TOC, REF, and other + live Word fields. ## Template handling @@ -115,6 +120,16 @@ If the required template cannot be found or retrieved, stop document generation 'The required Word template was not supplied or could not be accessed.' +## Template contract + +Use `{{path.to.value}}` for scalar text and `{{items[].field}}` in one sample +table row for repetition. Tokens may be split across Word runs; the engine +matches their visible paragraph text. It fills the main document, tables, +headers, and footers while preserving live Word fields. + +Read [`references/placeholder-contract.md`](references/placeholder-contract.md) +for the exact grammar, supported scope, limits, and troubleshooting. + ## Structured JSON Create JSON that reflects the **runtime template**. Use: @@ -129,24 +144,22 @@ Example shape (field names change to match the template): ```json { "document": { - "title": "{{document.title}}", - "type": "policy | procedure | report | paper | other", - "owner": "{{document.owner}}", - "version": "{{document.version}}", - "status": "{{document.status}}", - "audience": "{{document.audience}}" + "title": "Quarterly Operations Report", + "type": "Report", + "owner": "Operations", + "version": "1.0", + "status": "Draft", + "audience": "Leadership team" }, "sections": { - "": { - "content": "Generated section content", - "source_ids": ["SRC-001"] - } + "executive_summary": "Generated section content", + "purpose": "Generated section content" }, - "items": [ + "findings": [ { - "col_1": "Value for first repeating-table column", - "col_2": "Value for second column", - "source_ids": ["SRC-002"] + "finding": "Generated finding", + "impact": "Generated impact", + "owner": "Action owner" } ], "sources": [ @@ -159,7 +172,16 @@ Example shape (field names change to match the template): } ``` -A Leave Policy template would map `items` to `leave_types`; a procedure would map it to `steps`; a report would map it to `findings` or connector rows. Always adapt to the actual placeholders. +A Leave Policy template might use `leave_types`; a procedure might use `steps`; +a report might use `findings` or connector rows. Always use the array names +reported by template inspection. + +## Requirements + +The engine uses Python's standard library plus `lxml`, preinstalled in the +Copilot Studio sandbox. The sample-template builder and tests additionally use +preinstalled `python-docx` and Pillow. No network service or `pip install` is +needed in Copilot Studio. ## Quality and safety diff --git a/submissions/word-document-generator-from-template/assets/sample-data.json b/submissions/word-document-generator-from-template/assets/sample-data.json new file mode 100644 index 00000000..9238c139 --- /dev/null +++ b/submissions/word-document-generator-from-template/assets/sample-data.json @@ -0,0 +1,41 @@ +{ + "document": { + "title": "Quarterly Operations Report", + "type": "Report", + "owner": "Operations", + "version": "1.0", + "status": "Draft", + "audience": "Leadership team" + }, + "sections": { + "executive_summary": "Operations remained stable during the quarter.\nThree priority findings require follow-up.", + "purpose": "Summarize quarterly operating performance using approved records and identify actions for the next reporting period.", + "scope": "Covers service delivery, incident response, and supplier operations for the reporting quarter.", + "recommendations": "Confirm action owners and due dates.\nReview progress at the next monthly operations meeting.", + "appendix": "Source extracts and detailed calculations are retained with the approved reporting evidence." + }, + "findings": [ + { + "finding": "Service response targets were met.", + "impact": "Customer commitments remained on track.", + "owner": "Service Delivery" + }, + { + "finding": "Two supplier actions remain open.", + "impact": "Minor schedule risk next quarter.", + "owner": "Vendor Management" + }, + { + "finding": "Incident review completion improved.", + "impact": "Faster closure and stronger traceability.", + "owner": "Operations Assurance" + } + ], + "sources": [ + { + "source_id": "SRC-001", + "title": "Approved quarterly operations dataset", + "type": "connector" + } + ] +} diff --git a/submissions/word-document-generator-from-template/metadata.json b/submissions/word-document-generator-from-template/metadata.json index 5891192b..87121862 100644 --- a/submissions/word-document-generator-from-template/metadata.json +++ b/submissions/word-document-generator-from-template/metadata.json @@ -1,8 +1,8 @@ { "name": "Word Document Generator from Template", - "description": "Fill a Word template supplied at runtime (upload, SharePoint, OneDrive, or another connector) for any document type — policy, procedure, report, or paper. Uses approved knowledge, user files, and prior tool results. Preserves branding; never invents facts; returns a new DOCX.", + "description": "Deterministically fill a runtime Word template for any document type using approved knowledge, user files, and prior tool results. Handles split placeholders and repeating tables, preserves branding and live fields, validates output, and returns a new DOCX.", "platforms": ["Copilot Studio"], - "tags": ["word", "documents", "templates", "docx", "policy", "procedure", "reports", "sharepoint", "onedrive", "knowledge", "connectors", "drafting"], + "tags": ["word", "documents", "templates", "docx", "ooxml", "policy", "procedure", "reports", "sharepoint", "onedrive", "knowledge", "connectors"], "author": "Nazish Qasim", "authorUrl": "https://github.com/nazishqassim", "version": "1.0.0", diff --git a/submissions/word-document-generator-from-template/references/placeholder-contract.md b/submissions/word-document-generator-from-template/references/placeholder-contract.md new file mode 100644 index 00000000..39457e33 --- /dev/null +++ b/submissions/word-document-generator-from-template/references/placeholder-contract.md @@ -0,0 +1,132 @@ +# Deterministic DOCX placeholder contract + +`scripts/docx_template.py` fills plain-text placeholders in the main document, +tables, headers, and footers without flattening the surrounding Word runs. + +## Scalar values + +Use dotted JSON paths: + +```text +{{document.title}} +{{sections.executive_summary}} +{{metadata.approval.owner}} +``` + +JSON: + +```json +{ + "document": {"title": "Quarterly Operations Report"}, + "sections": {"executive_summary": "First line.\nSecond line."}, + "metadata": {"approval": {"owner": "Chief Operating Officer"}} +} +``` + +Strings, numbers, booleans, and `null` are accepted. Newlines become Word line +breaks. Objects and arrays cannot fill scalar placeholders. + +Missing scalar paths are filled with `Not specified in approved sources` and +listed in the fill summary under `defaulted_fields`. + +## Repeating table rows + +Put one sample row in the Word table and append `[]` to the array path: + +| Finding | Impact | Owner | +| --- | --- | --- | +| `{{findings[].finding}}` | `{{findings[].impact}}` | `{{findings[].owner}}` | + +JSON: + +```json +{ + "findings": [ + {"finding": "Finding A", "impact": "Low", "owner": "Team A"}, + {"finding": "Finding B", "impact": "High", "owner": "Team B"} + ] +} +``` + +The sample row is cloned twice and removed. An empty or missing array removes +the sample row and retains the table header. A row may reference exactly one +array path. Nested repeating arrays are not supported. + +Nested array paths are allowed: + +```text +{{report.findings[].title}} +{{report.findings[].rating}} +``` + +## Split Word runs + +Word may store a visible token across several runs: + +```xml +{{ +document.title +}} +``` + +The engine joins visible text while matching, then edits only the affected +`w:t` nodes. The replacement inherits the first token run's formatting. + +## Word fields + +The engine never writes to: + +- `w:instrText` +- `w:fldChar` +- `w:fldSimple` + +Before writing, it compares field instructions and field-character counts with +the template. If PAGE, NUMPAGES, TOC, REF, or another field changes, filling +fails and no output is written. + +It is safe to put a token beside live fields: + +```text +{{document.version}} | Page { PAGE } of { NUMPAGES } | {{document.status}} +``` + +Do not use a Word field itself as a placeholder. + +## Supported scope + +- `.docx` input and output +- Main body, tables, `header*.xml`, and `footer*.xml` +- Scalar plain text, numbers, booleans, `null`, and line breaks +- One repeating array per sample table row +- Split tokens contained within one paragraph or one table cell paragraph + +## Deliberate limits + +- No placeholder may span multiple paragraphs or table cells. +- No nested repeating arrays. +- No rich-text HTML/Markdown conversion inside a placeholder. +- No placeholders inside field instructions. +- Content controls and legacy MERGEFIELD values are preserved, not used as the + template syntax. +- Text embedded in unsupported package parts is not filled. + +Use Word styles, table formatting, and surrounding fixed text in the template +to achieve the desired visual design. + +## Commands + +```bash +# Discover exact fields before writing JSON +python scripts/docx_template.py inspect template.docx --output manifest.json + +# Fill a new document +python scripts/docx_template.py fill template.docx data.json output.docx \ + --summary fill-summary.json + +# Verify package integrity, unresolved tokens, and live fields +python scripts/docx_template.py validate output.docx \ + --template template.docx --output validation.json +``` + +All failures return exit code `2` and print a specific error. The original +template is never overwritten. diff --git a/submissions/word-document-generator-from-template/scripts/build_sample_template.py b/submissions/word-document-generator-from-template/scripts/build_sample_template.py new file mode 100644 index 00000000..309eeb8a --- /dev/null +++ b/submissions/word-document-generator-from-template/scripts/build_sample_template.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Regenerate the bundled sample DOCX used by docs and tests.""" + +from __future__ import annotations + +import argparse +import tempfile +from pathlib import Path + +from docx import Document +from docx.enum.section import WD_SECTION +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Inches, Pt, RGBColor +from PIL import Image, ImageDraw + + +def _split_token(paragraph, token: str, pieces: tuple[str, ...] | None = None): + """Add a token as several Word runs to test run-safe replacement.""" + parts = pieces or (token[:2], token[2:-2], token[-2:]) + for index, part in enumerate(parts): + run = paragraph.add_run(part) + if index == 1: + run.bold = True + + +def _field(paragraph, instruction: str) -> None: + run = paragraph.add_run() + begin = OxmlElement("w:fldChar") + begin.set(qn("w:fldCharType"), "begin") + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = f" {instruction} " + separate = OxmlElement("w:fldChar") + separate.set(qn("w:fldCharType"), "separate") + result = OxmlElement("w:t") + result.text = "1" + end = OxmlElement("w:fldChar") + end.set(qn("w:fldCharType"), "end") + run._r.extend((begin, instr, separate, result, end)) + + +def _style_document(document: Document) -> None: + styles = document.styles + styles["Normal"].font.name = "Aptos" + styles["Normal"].font.size = Pt(10) + for name, size, color in ( + ("Title", 26, RGBColor(31, 78, 121)), + ("Heading 1", 16, RGBColor(31, 78, 121)), + ("Heading 2", 12, RGBColor(68, 68, 68)), + ): + styles[name].font.name = "Aptos Display" + styles[name].font.size = Pt(size) + styles[name].font.color.rgb = color + + +def _make_logo(path: Path) -> None: + image = Image.new("RGB", (600, 140), "white") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((5, 5, 595, 135), radius=24, fill=(31, 78, 121)) + draw.text((35, 45), "CONTOSO | DOCUMENT TEMPLATE", fill="white") + image.save(path, "PNG") + + +def build_sample(output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + document = Document() + _style_document(document) + + section = document.sections[0] + section.top_margin = Inches(0.75) + section.bottom_margin = Inches(0.7) + section.left_margin = Inches(0.8) + section.right_margin = Inches(0.8) + + with tempfile.TemporaryDirectory() as temp_dir: + logo = Path(temp_dir) / "logo.png" + _make_logo(logo) + header = section.header + header_p = header.paragraphs[0] + header_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + header_p.add_run().add_picture(str(logo), width=Inches(4.5)) + header_p.add_run("\nInternal | ") + _split_token(header_p, "{{document.title}}") + + title = document.add_paragraph(style="Title") + _split_token(title, "{{document.title}}", ("{{document.", "title", "}}")) + subtitle = document.add_paragraph() + subtitle.add_run("Generated deterministically from approved sources").italic = True + + document.add_heading("Document control", level=1) + control = document.add_table(rows=5, cols=2) + control.style = "Table Grid" + metadata = [ + ("Type", "{{document.type}}"), + ("Owner", "{{document.owner}}"), + ("Version", "{{document.version}}"), + ("Status", "{{document.status}}"), + ("Audience", "{{document.audience}}"), + ] + for row, (label, token) in zip(control.rows, metadata): + row.cells[0].text = label + row.cells[0].paragraphs[0].runs[0].bold = True + row.cells[1].text = "" + _split_token(row.cells[1].paragraphs[0], token) + for cell in row.cells: + cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER + + for heading, token in ( + ("Executive summary", "{{sections.executive_summary}}"), + ("Purpose", "{{sections.purpose}}"), + ("Scope", "{{sections.scope}}"), + ): + document.add_heading(heading, level=1) + paragraph = document.add_paragraph() + _split_token(paragraph, token) + + document.add_heading("Findings", level=1) + findings = document.add_table(rows=2, cols=3) + findings.style = "Table Grid" + for cell, label in zip( + findings.rows[0].cells, ("Finding", "Impact", "Owner") + ): + cell.text = label + cell.paragraphs[0].runs[0].bold = True + row = findings.rows[1] + for cell, token in zip( + row.cells, + ( + "{{findings[].finding}}", + "{{findings[].impact}}", + "{{findings[].owner}}", + ), + ): + cell.text = "" + _split_token(cell.paragraphs[0], token) + + document.add_heading("Recommendations", level=1) + recommendations = document.add_paragraph() + _split_token(recommendations, "{{sections.recommendations}}") + + # A second section demonstrates that section properties and linked + # header/footer relationships survive the fill. + document.add_section(WD_SECTION.NEW_PAGE) + document.add_heading("Appendix", level=1) + appendix = document.add_paragraph() + _split_token(appendix, "{{sections.appendix}}") + + for sec in document.sections: + footer = sec.footer + footer.is_linked_to_previous = True + footer_p = section.footer.paragraphs[0] + footer_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + _split_token(footer_p, "{{document.version}}") + footer_p.add_run(" | Page ") + _field(footer_p, "PAGE") + footer_p.add_run(" of ") + _field(footer_p, "NUMPAGES") + footer_p.add_run(" | ") + _split_token(footer_p, "{{document.status}}") + + document.save(output) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "output", + nargs="?", + default=str( + Path(__file__).resolve().parents[1] / "assets" / "sample-template.docx" + ), + ) + args = parser.parse_args() + output = Path(args.output).resolve() + build_sample(output) + print(output) + + +if __name__ == "__main__": + main() diff --git a/submissions/word-document-generator-from-template/scripts/docx_template.py b/submissions/word-document-generator-from-template/scripts/docx_template.py new file mode 100644 index 00000000..8a3e9068 --- /dev/null +++ b/submissions/word-document-generator-from-template/scripts/docx_template.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +"""Deterministic, field-safe DOCX template inspection and filling. + +The engine edits only visible WordprocessingML text in the main document, +headers, and footers. It preserves all other package parts and never rewrites +Word field instructions (PAGE, NUMPAGES, TOC, cross-references, and similar). + +Template contract: + Scalars: {{document.title}} or {{sections.purpose}} + Repeating rows: {{items[].name}} (one array path per template table row) + +Usage: + python docx_template.py inspect template.docx --output manifest.json + python docx_template.py fill template.docx data.json output.docx + python docx_template.py validate output.docx --template template.docx +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import re +import sys +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping + +from lxml import etree + + +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +XML = "http://www.w3.org/XML/1998/namespace" +NS = {"w": W} +XML_SPACE = f"{{{XML}}}space" + +REQUIRED_PARTS = {"[Content_Types].xml", "word/document.xml"} +SUPPORTED_PART_RE = re.compile(r"^word/(?:document|header\d+|footer\d+)\.xml$") +TOKEN_RE = re.compile( + r"\{\{\s*" + r"([A-Za-z_][A-Za-z0-9_-]*(?:\[\])?" + r"(?:\.[A-Za-z_][A-Za-z0-9_-]*(?:\[\])?)*)" + r"\s*\}\}" +) +TOKEN_CANDIDATE_RE = re.compile(r"\{\{.*?\}\}", re.DOTALL) +MAX_PACKAGE_FILES = 10_000 +MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 +DEFAULT_MISSING = "Not specified in approved sources" + + +class TemplateError(RuntimeError): + """Raised when the template contract or DOCX package is invalid.""" + + +@dataclass +class FillReport: + template: str + output: str + replaced_fields: set[str] = field(default_factory=set) + defaulted_fields: set[str] = field(default_factory=set) + repeated_rows: dict[str, int] = field(default_factory=dict) + modified_parts: set[str] = field(default_factory=set) + field_signature_preserved: bool = True + + def as_dict(self) -> dict[str, Any]: + return { + "template": self.template, + "output": self.output, + "replaced_fields": sorted(self.replaced_fields), + "defaulted_fields": sorted(self.defaulted_fields), + "repeated_rows": dict(sorted(self.repeated_rows.items())), + "modified_parts": sorted(self.modified_parts), + "field_signature_preserved": self.field_signature_preserved, + } + + +def _w(tag: str) -> str: + return f"{{{W}}}{tag}" + + +def _safe_parser() -> etree.XMLParser: + return etree.XMLParser( + resolve_entities=False, + no_network=True, + remove_blank_text=False, + recover=False, + huge_tree=False, + ) + + +def _parse_xml(content: bytes, part_name: str) -> etree._Element: + try: + return etree.fromstring(content, parser=_safe_parser()) + except (etree.XMLSyntaxError, ValueError) as exc: + raise TemplateError(f"Invalid XML in {part_name}: {exc}") from exc + + +def _serialize_xml(root: etree._Element) -> bytes: + return etree.tostring( + root, + encoding="UTF-8", + xml_declaration=True, + standalone=True, + ) + + +def _validate_zip_entries(infos: list[zipfile.ZipInfo]) -> None: + if len(infos) > MAX_PACKAGE_FILES: + raise TemplateError( + f"DOCX contains too many package entries ({len(infos)} > " + f"{MAX_PACKAGE_FILES})." + ) + total = sum(info.file_size for info in infos) + if total > MAX_UNCOMPRESSED_BYTES: + raise TemplateError( + f"DOCX uncompressed size is too large ({total} bytes > " + f"{MAX_UNCOMPRESSED_BYTES})." + ) + names = [info.filename for info in infos] + if len(names) != len(set(names)): + raise TemplateError("DOCX contains duplicate package entry names.") + missing = REQUIRED_PARTS - set(names) + if missing: + raise TemplateError( + "Not a valid DOCX package; missing: " + ", ".join(sorted(missing)) + ) + + +def _read_package(path: str | os.PathLike[str]) -> tuple[ + dict[str, bytes], dict[str, zipfile.ZipInfo] +]: + source = Path(path) + if not source.is_file(): + raise TemplateError(f"DOCX file not found: {source}") + if source.suffix.lower() != ".docx": + raise TemplateError(f"Expected a .docx file: {source}") + try: + with zipfile.ZipFile(source, "r") as archive: + infos = archive.infolist() + _validate_zip_entries(infos) + content = {info.filename: archive.read(info.filename) for info in infos} + metadata = {info.filename: info for info in infos} + except (zipfile.BadZipFile, OSError) as exc: + raise TemplateError(f"Cannot read DOCX package {source}: {exc}") from exc + return content, metadata + + +def _supported_parts(package: Mapping[str, bytes]) -> list[str]: + return sorted(name for name in package if SUPPORTED_PART_RE.match(name)) + + +def _text_nodes(paragraph: etree._Element) -> list[etree._Element]: + """Visible text nodes, excluding simple and complex Word-field contents.""" + nodes: list[etree._Element] = [] + field_depth = 0 + for node in paragraph.iter(): + if node.tag == _w("fldChar"): + kind = node.get(_w("fldCharType"), "") + if kind == "begin": + field_depth += 1 + elif kind == "end": + field_depth = max(0, field_depth - 1) + continue + if node.tag != _w("t") or field_depth: + continue + if any(ancestor.tag == _w("fldSimple") for ancestor in node.iterancestors()): + continue + nodes.append(node) + return nodes + + +def _paragraph_text(paragraph: etree._Element) -> str: + return "".join(node.text or "" for node in _text_nodes(paragraph)) + + +def _row_text(row: etree._Element) -> str: + return "".join(_paragraph_text(p) for p in row.iter(_w("p"))) + + +def _set_text(node: etree._Element, text: str) -> None: + node.text = text + if text[:1].isspace() or text[-1:].isspace(): + node.set(XML_SPACE, "preserve") + elif XML_SPACE in node.attrib: + del node.attrib[XML_SPACE] + + +def _find_node_offset( + spans: list[tuple[int, int, etree._Element]], position: int, *, end: bool = False +) -> tuple[int, etree._Element, int]: + for index, (start, stop, node) in enumerate(spans): + if start <= position < stop or (end and position == stop and stop > start): + return index, node, position - start + if spans and position == spans[-1][1]: + start, _, node = spans[-1] + return len(spans) - 1, node, position - start + raise TemplateError("Internal placeholder offset could not be mapped to a run.") + + +def _replace_in_paragraph( + paragraph: etree._Element, + resolver: Callable[[str], str | None], + *, + allow_array_tokens: bool, +) -> set[str]: + """Replace tokens even when Word split them across multiple runs.""" + nodes = _text_nodes(paragraph) + if not nodes: + return set() + text = "".join(node.text or "" for node in nodes) + matches = list(TOKEN_RE.finditer(text)) + replaced: set[str] = set() + if not matches: + return replaced + + spans: list[tuple[int, int, etree._Element]] = [] + cursor = 0 + for node in nodes: + value = node.text or "" + spans.append((cursor, cursor + len(value), node)) + cursor += len(value) + + # Reverse order keeps original offsets valid when two tokens share a run. + for match in reversed(matches): + key = match.group(1) + is_array = "[]" in key + if is_array and not allow_array_tokens: + continue + replacement = resolver(key) + if replacement is None: + continue + + first_i, first, first_offset = _find_node_offset(spans, match.start()) + last_i, last, last_offset = _find_node_offset( + spans, match.end(), end=True + ) + first_text = first.text or "" + last_text = last.text or "" + + if first is last: + _set_text( + first, + first_text[:first_offset] + replacement + first_text[last_offset:], + ) + else: + _set_text(first, first_text[:first_offset] + replacement) + for node_i in range(first_i + 1, last_i): + _set_text(spans[node_i][2], "") + _set_text(last, last_text[last_offset:]) + replaced.add(key) + return replaced + + +def _convert_newlines(root: etree._Element) -> None: + """Turn replacement newlines into Word line-break elements.""" + for text_node in list(root.iter(_w("t"))): + value = text_node.text or "" + if "\n" not in value: + continue + parent = text_node.getparent() + if parent is None or parent.tag != _w("r"): + continue + index = parent.index(text_node) + parent.remove(text_node) + lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n") + for line_i, line in enumerate(lines): + new_text = etree.Element(_w("t")) + _set_text(new_text, line) + parent.insert(index, new_text) + index += 1 + if line_i != len(lines) - 1: + parent.insert(index, etree.Element(_w("br"))) + index += 1 + + +def _tokens_in_element(element: etree._Element) -> list[str]: + tokens: list[str] = [] + for paragraph in element.iter(_w("p")): + tokens.extend(match.group(1) for match in TOKEN_RE.finditer(_paragraph_text(paragraph))) + return tokens + + +def _array_token(key: str) -> tuple[str, str] | None: + segments = key.split(".") + marked = [i for i, segment in enumerate(segments) if segment.endswith("[]")] + if not marked: + return None + if len(marked) > 1: + raise TemplateError(f"Nested repeating arrays are not supported: {key}") + index = marked[0] + segments[index] = segments[index][:-2] + array_path = ".".join(segments[: index + 1]) + item_path = ".".join(segments[index + 1 :]) + if not item_path: + raise TemplateError(f"Repeating token must name an item field: {key}") + return array_path, item_path + + +_MISSING = object() + + +def _lookup(data: Any, path: str) -> Any: + current = data + if not path: + return current + for segment in path.split("."): + if isinstance(current, Mapping) and segment in current: + current = current[segment] + else: + return _MISSING + return current + + +def _as_text(value: Any, path: str) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + raise TemplateError( + f"Placeholder {path!r} requires a scalar value, got " + f"{type(value).__name__}." + ) + + +def _expand_repeating_rows( + root: etree._Element, + data: Mapping[str, Any], + missing_value: str, + report: FillReport, +) -> bool: + changed = False + for row in list(root.iter(_w("tr"))): + array_tokens = [ + key for key in _tokens_in_element(row) if _array_token(key) is not None + ] + if not array_tokens: + continue + roots = {_array_token(key)[0] for key in array_tokens} # type: ignore[index] + if len(roots) != 1: + raise TemplateError( + "A repeating table row may reference only one array; found: " + + ", ".join(sorted(roots)) + ) + array_path = next(iter(roots)) + items = _lookup(data, array_path) + if items is _MISSING: + items = [] + report.defaulted_fields.add(array_path) + if not isinstance(items, list): + raise TemplateError( + f"Repeating row {array_path!r} requires a JSON array." + ) + parent = row.getparent() + if parent is None: + raise TemplateError("Repeating table row has no parent table.") + insert_at = parent.index(row) + for item_index, item in enumerate(items): + if not isinstance(item, Mapping): + raise TemplateError( + f"{array_path}[{item_index}] must be a JSON object." + ) + clone = copy.deepcopy(row) + + def resolve_array(key: str) -> str | None: + parsed = _array_token(key) + if parsed is None or parsed[0] != array_path: + return None + item_path = parsed[1] + value = _lookup(item, item_path) + if value is _MISSING: + report.defaulted_fields.add( + f"{array_path}[{item_index}].{item_path}" + ) + return missing_value + report.replaced_fields.add( + f"{array_path}[{item_index}].{item_path}" + ) + return _as_text(value, f"{array_path}[].{item_path}") + + for paragraph in clone.iter(_w("p")): + _replace_in_paragraph( + paragraph, resolve_array, allow_array_tokens=True + ) + parent.insert(insert_at, clone) + insert_at += 1 + parent.remove(row) + report.repeated_rows[array_path] = report.repeated_rows.get(array_path, 0) + len(items) + changed = True + return changed + + +def _replace_scalars( + root: etree._Element, + data: Mapping[str, Any], + missing_value: str, + report: FillReport, +) -> bool: + changed = False + + def resolve_scalar(key: str) -> str | None: + if "[]" in key: + return None + value = _lookup(data, key) + if value is _MISSING: + report.defaulted_fields.add(key) + return missing_value + report.replaced_fields.add(key) + return _as_text(value, key) + + for paragraph in root.iter(_w("p")): + replaced = _replace_in_paragraph( + paragraph, resolve_scalar, allow_array_tokens=False + ) + changed = changed or bool(replaced) + return changed + + +def _field_signature(root: etree._Element) -> dict[str, Any]: + instructions = [ + re.sub(r"\s+", " ", (node.text or "").strip()) + for node in root.iter(_w("instrText")) + ] + simple = [ + re.sub(r"\s+", " ", (node.get(_w("instr")) or "").strip()) + for node in root.iter(_w("fldSimple")) + ] + fld_chars: dict[str, int] = {} + for node in root.iter(_w("fldChar")): + kind = node.get(_w("fldCharType"), "") + fld_chars[kind] = fld_chars.get(kind, 0) + 1 + return { + "instructions": instructions, + "simple_fields": simple, + "field_chars": dict(sorted(fld_chars.items())), + } + + +def _package_field_signature(package: Mapping[str, bytes]) -> dict[str, Any]: + signature: dict[str, Any] = {} + for part in _supported_parts(package): + root = _parse_xml(package[part], part) + part_sig = _field_signature(root) + if ( + part_sig["instructions"] + or part_sig["simple_fields"] + or part_sig["field_chars"] + ): + signature[part] = part_sig + return signature + + +def _scan_part( + root: etree._Element, +) -> tuple[set[str], dict[str, set[str]], list[str]]: + scalar: set[str] = set() + arrays: dict[str, set[str]] = {} + malformed: list[str] = [] + for paragraph in root.iter(_w("p")): + text = _paragraph_text(paragraph) + valid_spans = {match.span() for match in TOKEN_RE.finditer(text)} + for candidate in TOKEN_CANDIDATE_RE.finditer(text): + if candidate.span() not in valid_spans: + malformed.append(candidate.group(0)) + without_valid_tokens = TOKEN_RE.sub("", text) + if "{{" in without_valid_tokens or "}}" in without_valid_tokens: + excerpt = without_valid_tokens.strip() + malformed.append(excerpt[:120] or "unmatched placeholder braces") + for match in TOKEN_RE.finditer(text): + key = match.group(1) + parsed = _array_token(key) + if parsed is None: + scalar.add(key) + else: + arrays.setdefault(parsed[0], set()).add(parsed[1]) + return scalar, arrays, malformed + + +def inspect_template(path: str | os.PathLike[str]) -> dict[str, Any]: + package, _ = _read_package(path) + scalars: set[str] = set() + arrays: dict[str, set[str]] = {} + malformed: list[str] = [] + by_part: dict[str, dict[str, Any]] = {} + for part in _supported_parts(package): + root = _parse_xml(package[part], part) + part_scalars, part_arrays, part_malformed = _scan_part(root) + scalars.update(part_scalars) + malformed.extend(part_malformed) + for array_path, fields in part_arrays.items(): + arrays.setdefault(array_path, set()).update(fields) + by_part[part] = { + "scalar_placeholders": sorted(part_scalars), + "repeating_arrays": { + key: sorted(value) for key, value in sorted(part_arrays.items()) + }, + } + if malformed: + raise TemplateError( + "Malformed placeholder(s): " + ", ".join(sorted(set(malformed))) + ) + return { + "template": Path(path).name, + "scalar_placeholders": sorted(scalars), + "repeating_arrays": { + key: sorted(value) for key, value in sorted(arrays.items()) + }, + "parts": by_part, + "word_fields": _package_field_signature(package), + } + + +def _unresolved_tokens(package: Mapping[str, bytes]) -> list[dict[str, str]]: + unresolved: list[dict[str, str]] = [] + for part in _supported_parts(package): + root = _parse_xml(package[part], part) + for paragraph in root.iter(_w("p")): + text = _paragraph_text(paragraph) + for match in TOKEN_CANDIDATE_RE.finditer(text): + unresolved.append({"part": part, "token": match.group(0)}) + without_candidates = TOKEN_CANDIDATE_RE.sub("", text) + if "{{" in without_candidates or "}}" in without_candidates: + unresolved.append( + {"part": part, "token": "unmatched placeholder braces"} + ) + return unresolved + + +def _write_package( + output_path: Path, + package: Mapping[str, bytes], + metadata: Mapping[str, zipfile.ZipInfo], +) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w") as archive: + for name, content in package.items(): + info = metadata[name] + archive.writestr(info, content) + + +def fill_template( + template_path: str | os.PathLike[str], + data: Mapping[str, Any], + output_path: str | os.PathLike[str], + *, + missing_value: str = DEFAULT_MISSING, +) -> dict[str, Any]: + source = Path(template_path).resolve() + output = Path(output_path).resolve() + if source == output: + raise TemplateError("Output path must differ from the template path.") + if output.suffix.lower() != ".docx": + raise TemplateError("Output path must end with .docx.") + if not isinstance(data, Mapping): + raise TemplateError("Fill data must be a JSON object.") + + package, metadata = _read_package(source) + original_fields = _package_field_signature(package) + report = FillReport(template=str(source), output=str(output)) + + for part in _supported_parts(package): + root = _parse_xml(package[part], part) + changed = _expand_repeating_rows(root, data, missing_value, report) + changed = _replace_scalars(root, data, missing_value, report) or changed + if changed: + _convert_newlines(root) + package[part] = _serialize_xml(root) + report.modified_parts.add(part) + + unresolved = _unresolved_tokens(package) + if unresolved: + details = ", ".join( + f"{item['token']} in {item['part']}" for item in unresolved + ) + raise TemplateError(f"Unresolved placeholder(s) remain: {details}") + + output_fields = _package_field_signature(package) + report.field_signature_preserved = output_fields == original_fields + if not report.field_signature_preserved: + raise TemplateError( + "Word field instructions changed during filling; output was not written." + ) + + _write_package(output, package, metadata) + validation = validate_docx(output, template_path=source) + result = report.as_dict() + result["validation"] = validation + return result + + +def validate_docx( + path: str | os.PathLike[str], + *, + template_path: str | os.PathLike[str] | None = None, +) -> dict[str, Any]: + package, _ = _read_package(path) + # Parse every XML part so corrupt output fails loudly. + for name, content in package.items(): + if name.endswith(".xml") or name.endswith(".rels"): + _parse_xml(content, name) + unresolved = _unresolved_tokens(package) + if unresolved: + details = ", ".join( + f"{item['token']} in {item['part']}" for item in unresolved + ) + raise TemplateError(f"Unresolved placeholder(s): {details}") + + fields = _package_field_signature(package) + fields_preserved: bool | None = None + if template_path is not None: + template, _ = _read_package(template_path) + fields_preserved = fields == _package_field_signature(template) + if not fields_preserved: + raise TemplateError("Word field signature differs from the template.") + + return { + "document": str(Path(path).resolve()), + "valid_docx": True, + "unresolved_placeholders": [], + "field_signature_preserved": fields_preserved, + "word_fields": fields, + "sha256": hashlib.sha256(Path(path).read_bytes()).hexdigest(), + } + + +def _load_json(path: str | os.PathLike[str]) -> Any: + try: + with open(path, "r", encoding="utf-8-sig") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + raise TemplateError(f"Cannot read JSON {path}: {exc}") from exc + + +def _write_json(data: Mapping[str, Any], destination: str | None) -> None: + text = json.dumps(data, indent=2, ensure_ascii=False) + if destination: + path = Path(destination) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text + "\n", encoding="utf-8") + print(text) + + +def _build_cli() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Inspect, fill, and validate deterministic Word templates." + ) + sub = parser.add_subparsers(dest="command", required=True) + + inspect_p = sub.add_parser("inspect", help="Discover placeholders and fields") + inspect_p.add_argument("template", help="Input .docx template") + inspect_p.add_argument("--output", help="Optional manifest JSON path") + + fill_p = sub.add_parser("fill", help="Fill a template from JSON") + fill_p.add_argument("template", help="Input .docx template") + fill_p.add_argument("data", help="Template-shaped JSON object") + fill_p.add_argument("output", help="New output .docx path") + fill_p.add_argument( + "--missing", + default=DEFAULT_MISSING, + help=f"Fallback for absent scalar values (default: {DEFAULT_MISSING!r})", + ) + fill_p.add_argument("--summary", help="Optional fill-summary JSON path") + + validate_p = sub.add_parser("validate", help="Validate an output DOCX") + validate_p.add_argument("document", help="DOCX to validate") + validate_p.add_argument( + "--template", help="Original template for Word-field comparison" + ) + validate_p.add_argument("--output", help="Optional validation JSON path") + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + args = _build_cli().parse_args(list(argv) if argv is not None else None) + try: + if args.command == "inspect": + result = inspect_template(args.template) + _write_json(result, args.output) + elif args.command == "fill": + result = fill_template( + args.template, + _load_json(args.data), + args.output, + missing_value=args.missing, + ) + _write_json(result, args.summary) + else: + result = validate_docx( + args.document, template_path=args.template + ) + _write_json(result, args.output) + return 0 + except TemplateError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/submissions/word-document-generator-from-template/scripts/test_docx_template.py b/submissions/word-document-generator-from-template/scripts/test_docx_template.py new file mode 100644 index 00000000..51d93d5a --- /dev/null +++ b/submissions/word-document-generator-from-template/scripts/test_docx_template.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Regression tests for deterministic DOCX template filling.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +from docx import Document +from lxml import etree + +from build_sample_template import build_sample +from docx_template import ( + TemplateError, + fill_template, + inspect_template, + validate_docx, +) + + +HERE = Path(__file__).resolve().parent +ASSETS = HERE.parent / "assets" +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +NS = {"w": W} + + +def _read_zip(path: Path) -> dict[str, bytes]: + with zipfile.ZipFile(path, "r") as archive: + return {name: archive.read(name) for name in archive.namelist()} + + +def _write_zip(path: Path, parts: dict[str, bytes]) -> None: + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + for name, value in parts.items(): + archive.writestr(name, value) + + +def _visible_text(xml: bytes) -> str: + root = etree.fromstring(xml) + return "".join(root.xpath(".//w:t/text()", namespaces=NS)) + + +class DocxTemplateTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.template = self.root / "template.docx" + build_sample(self.template) + self.data = json.loads( + (ASSETS / "sample-data.json").read_text(encoding="utf-8") + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_inspect_finds_split_tokens_arrays_and_fields(self) -> None: + manifest = inspect_template(self.template) + self.assertIn("document.title", manifest["scalar_placeholders"]) + self.assertIn("sections.executive_summary", manifest["scalar_placeholders"]) + self.assertEqual( + manifest["repeating_arrays"], + {"findings": ["finding", "impact", "owner"]}, + ) + field_json = json.dumps(manifest["word_fields"]) + self.assertIn("PAGE", field_json) + self.assertIn("NUMPAGES", field_json) + self.assertIn("word/header1.xml", manifest["parts"]) + self.assertIn("word/footer1.xml", manifest["parts"]) + + def test_fill_preserves_fields_package_parts_and_original(self) -> None: + original_hash = hashlib.sha256(self.template.read_bytes()).hexdigest() + original_parts = _read_zip(self.template) + output = self.root / "nested" / "filled.docx" + + report = fill_template(self.template, self.data, output) + + self.assertTrue(output.is_file()) + self.assertEqual( + hashlib.sha256(self.template.read_bytes()).hexdigest(), original_hash + ) + self.assertTrue(report["field_signature_preserved"]) + self.assertEqual(report["repeated_rows"], {"findings": 3}) + self.assertEqual(report["defaulted_fields"], []) + self.assertTrue(report["validation"]["valid_docx"]) + + result_parts = _read_zip(output) + for name, content in original_parts.items(): + if name not in { + "word/document.xml", + "word/header1.xml", + "word/footer1.xml", + }: + self.assertEqual( + result_parts[name], + content, + f"Unmodified package part changed: {name}", + ) + + text = " ".join( + _visible_text(result_parts[name]) + for name in ("word/document.xml", "word/header1.xml", "word/footer1.xml") + ) + self.assertIn("Quarterly Operations Report", text) + self.assertIn("Service response targets were met.", text) + self.assertNotIn("{{", text) + + document = Document(output) + findings_table = next( + table for table in document.tables if table.rows[0].cells[0].text == "Finding" + ) + self.assertEqual(len(findings_table.rows), 4) # header + 3 items + + def test_newlines_become_word_line_breaks(self) -> None: + output = self.root / "line-breaks.docx" + fill_template(self.template, self.data, output) + root = etree.fromstring(_read_zip(output)["word/document.xml"]) + self.assertGreaterEqual(len(root.xpath(".//w:br", namespaces=NS)), 2) + + def test_missing_scalar_uses_fallback_and_reports_it(self) -> None: + data = copy.deepcopy(self.data) + del data["document"]["audience"] + output = self.root / "missing.docx" + report = fill_template(self.template, data, output) + self.assertIn("document.audience", report["defaulted_fields"]) + text = _visible_text(_read_zip(output)["word/document.xml"]) + self.assertIn("Not specified in approved sources", text) + + def test_empty_array_removes_sample_row(self) -> None: + data = copy.deepcopy(self.data) + data["findings"] = [] + output = self.root / "empty.docx" + report = fill_template(self.template, data, output) + self.assertEqual(report["repeated_rows"], {"findings": 0}) + document = Document(output) + findings_table = next( + table for table in document.tables if table.rows[0].cells[0].text == "Finding" + ) + self.assertEqual(len(findings_table.rows), 1) + + def test_wrong_array_type_fails_without_output(self) -> None: + data = copy.deepcopy(self.data) + data["findings"] = {"finding": "not an array"} + output = self.root / "bad-array.docx" + with self.assertRaisesRegex(TemplateError, "requires a JSON array"): + fill_template(self.template, data, output) + self.assertFalse(output.exists()) + + def test_complex_scalar_fails_without_output(self) -> None: + data = copy.deepcopy(self.data) + data["document"]["title"] = {"nested": "not supported"} + output = self.root / "bad-scalar.docx" + with self.assertRaisesRegex(TemplateError, "requires a scalar"): + fill_template(self.template, data, output) + self.assertFalse(output.exists()) + + def test_input_output_collision_is_rejected(self) -> None: + with self.assertRaisesRegex(TemplateError, "must differ"): + fill_template(self.template, self.data, self.template) + + def test_malformed_docx_is_rejected(self) -> None: + bad = self.root / "bad.docx" + bad.write_text("not a zip", encoding="utf-8") + with self.assertRaisesRegex(TemplateError, "Cannot read DOCX"): + inspect_template(bad) + + def test_malformed_placeholder_is_rejected(self) -> None: + parts = _read_zip(self.template) + root = etree.fromstring(parts["word/document.xml"]) + target = next( + node + for node in root.xpath(".//w:t", namespaces=NS) + if "sections.executive_summary" in (node.text or "") + ) + target.text = "sections executive_summary" + parts["word/document.xml"] = etree.tostring( + root, xml_declaration=True, encoding="UTF-8" + ) + malformed = self.root / "malformed-token.docx" + _write_zip(malformed, parts) + with self.assertRaisesRegex(TemplateError, "Malformed placeholder"): + inspect_template(malformed) + + def test_unmatched_placeholder_braces_are_rejected(self) -> None: + parts = _read_zip(self.template) + root = etree.fromstring(parts["word/document.xml"]) + target = next( + node + for node in root.xpath(".//w:t", namespaces=NS) + if "sections.executive_summary" in (node.text or "") + ) + target.text = "sections.executive_summary" + closing = target.getparent().getnext() + if closing is not None: + closing_text = closing.find(f"{{{W}}}t") + if closing_text is not None: + closing_text.text = "" + parts["word/document.xml"] = etree.tostring( + root, xml_declaration=True, encoding="UTF-8" + ) + malformed = self.root / "unmatched-token.docx" + _write_zip(malformed, parts) + with self.assertRaisesRegex(TemplateError, "Malformed placeholder"): + inspect_template(malformed) + + def test_validate_detects_removed_live_field(self) -> None: + output = self.root / "filled.docx" + fill_template(self.template, self.data, output) + parts = _read_zip(output) + footer = etree.fromstring(parts["word/footer1.xml"]) + instr = footer.xpath(".//w:instrText", namespaces=NS)[0] + instr.getparent().remove(instr) + parts["word/footer1.xml"] = etree.tostring( + footer, xml_declaration=True, encoding="UTF-8" + ) + damaged = self.root / "damaged.docx" + _write_zip(damaged, parts) + with self.assertRaisesRegex(TemplateError, "field signature"): + validate_docx(damaged, template_path=self.template) + + def test_cli_inspect_fill_validate(self) -> None: + data_path = self.root / "data.json" + data_path.write_text(json.dumps(self.data), encoding="utf-8") + manifest = self.root / "manifest.json" + output = self.root / "cli-output.docx" + summary = self.root / "summary.json" + validation = self.root / "validation.json" + script = HERE / "docx_template.py" + + commands = [ + [ + sys.executable, + str(script), + "inspect", + str(self.template), + "--output", + str(manifest), + ], + [ + sys.executable, + str(script), + "fill", + str(self.template), + str(data_path), + str(output), + "--summary", + str(summary), + ], + [ + sys.executable, + str(script), + "validate", + str(output), + "--template", + str(self.template), + "--output", + str(validation), + ], + ] + for command in commands: + result = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8" + ) + self.assertEqual(result.returncode, 0, result.stderr) + for path in (manifest, output, summary, validation): + self.assertTrue(path.exists(), path) + self.assertTrue( + json.loads(validation.read_text(encoding="utf-8"))["valid_docx"] + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 5becf3fd12cedd3f360d03f455bea3d7d6741035 Mon Sep 17 00:00:00 2001 From: Nazish Qasim Date: Mon, 17 Aug 2026 23:04:00 +1000 Subject: [PATCH 8/8] Refactor Word Document Generator: Improve template processing and validation logic. Enhance JSON structure handling and update documentation for better clarity on features and usage. --- .../assets/sample-template.docx | Bin 0 -> 39969 bytes .../assets/sample-template.manifest.json | 74 ++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 submissions/word-document-generator-from-template/assets/sample-template.docx create mode 100644 submissions/word-document-generator-from-template/assets/sample-template.manifest.json diff --git a/submissions/word-document-generator-from-template/assets/sample-template.docx b/submissions/word-document-generator-from-template/assets/sample-template.docx new file mode 100644 index 0000000000000000000000000000000000000000..fb9aebe04ada18930a8645221ad01a72acdc4a34 GIT binary patch literal 39969 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 z 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%0A}&30~&ZAm`=s7 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 G{q{euP;_+w literal 0 HcmV?d00001 diff --git a/submissions/word-document-generator-from-template/assets/sample-template.manifest.json b/submissions/word-document-generator-from-template/assets/sample-template.manifest.json new file mode 100644 index 00000000..c421869f --- /dev/null +++ b/submissions/word-document-generator-from-template/assets/sample-template.manifest.json @@ -0,0 +1,74 @@ +{ + "template": "sample-template.docx", + "scalar_placeholders": [ + "document.audience", + "document.owner", + "document.status", + "document.title", + "document.type", + "document.version", + "sections.appendix", + "sections.executive_summary", + "sections.purpose", + "sections.recommendations", + "sections.scope" + ], + "repeating_arrays": { + "findings": [ + "finding", + "impact", + "owner" + ] + }, + "parts": { + "word/document.xml": { + "scalar_placeholders": [ + "document.audience", + "document.owner", + "document.status", + "document.title", + "document.type", + "document.version", + "sections.appendix", + "sections.executive_summary", + "sections.purpose", + "sections.recommendations", + "sections.scope" + ], + "repeating_arrays": { + "findings": [ + "finding", + "impact", + "owner" + ] + } + }, + "word/footer1.xml": { + "scalar_placeholders": [ + "document.status", + "document.version" + ], + "repeating_arrays": {} + }, + "word/header1.xml": { + "scalar_placeholders": [ + "document.title" + ], + "repeating_arrays": {} + } + }, + "word_fields": { + "word/footer1.xml": { + "instructions": [ + "PAGE", + "NUMPAGES" + ], + "simple_fields": [], + "field_chars": { + "begin": 2, + "end": 2, + "separate": 2 + } + } + } +}