From 18d9b24748b5e2758b9f672d6f268ddf81152817 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:26:09 +1100 Subject: [PATCH 1/3] fix(server): Word text boxes are no longer extracted twice for agents Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/documentTextWorker.ts | 3 ++- apps/server/src/provider/userInputAttachments.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts index 569493906d385..e4c7be4d2c06e 100644 --- a/apps/server/src/provider/documentTextWorker.ts +++ b/apps/server/src/provider/documentTextWorker.ts @@ -47,7 +47,8 @@ function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { stack.push(node); }); parser.on("closetag", () => { - stack.pop(); + // Markup-compatibility fallbacks repeat their mc:Choice content, e.g. Word text boxes. + if (stack.pop()!.name === "Fallback") stack.at(-1)!.children.pop(); }); const text = (value: string) => { stack.at(-1)!.children.push(value); diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index 81c167c038d87..e55473dc73291 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -554,6 +554,16 @@ describe("question answer paths", () => { expected: ["Start", "[Extracted text truncated at document extraction limits.]"], absent: ["Unneeded"], }, + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": + 'BodyCallout boxCallout box', + }, + expected: ["Body\nCallout box"], + occurrences: { text: "Callout box", count: 1 }, + }, { ext: "docx", bounded: true, From e4039d27c8b944ef86a16590d42a595acc567060 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:38:44 +1100 Subject: [PATCH 2/3] fix(server): keep Word fallback text when no choice has any Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/documentTextWorker.ts | 9 ++++++++- apps/server/src/provider/userInputAttachments.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts index e4c7be4d2c06e..9412c70222432 100644 --- a/apps/server/src/provider/documentTextWorker.ts +++ b/apps/server/src/provider/documentTextWorker.ts @@ -48,7 +48,12 @@ function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { }); parser.on("closetag", () => { // Markup-compatibility fallbacks repeat their mc:Choice content, e.g. Word text boxes. - if (stack.pop()!.name === "Fallback") stack.at(-1)!.children.pop(); + // Keep one when no Choice carries text, such as a chart with a VML caption. + if (stack.pop()!.name !== "Fallback") return; + const parent = stack.at(-1)!; + if (children(parent).some((child) => child.name === "Choice" && hasText(child))) { + parent.children.pop(); + } }); const text = (value: string) => { stack.at(-1)!.children.push(value); @@ -61,6 +66,8 @@ function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { const children = (node: XmlNode) => node.children.filter((child): child is XmlNode => typeof child !== "string"); +const hasText = (node: XmlNode): boolean => + node.children.some((child) => (typeof child === "string" ? child.trim() !== "" : hasText(child))); function* all(node: XmlNode, name: string | readonly string[]): Generator { for (const child of children(node)) { if (typeof name === "string" ? child.name === name : name.includes(child.name)) yield child; diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index e55473dc73291..9d8f3718dd4b8 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -564,6 +564,15 @@ describe("question answer paths", () => { expected: ["Body\nCallout box"], occurrences: { text: "Callout box", count: 1 }, }, + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": + 'Chart caption', + }, + expected: ["Chart caption"], + }, { ext: "docx", bounded: true, From 7ce5f6cfb72471b17e49fa05794c4a0874795a97 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:53:34 +1100 Subject: [PATCH 3/3] fix(server): fallback check no longer rescans siblings Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/documentTextWorker.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts index 9412c70222432..1abdbd78030d5 100644 --- a/apps/server/src/provider/documentTextWorker.ts +++ b/apps/server/src/provider/documentTextWorker.ts @@ -22,6 +22,8 @@ interface XmlNode { attrs: Record; children: Array; spaceCount?: number; + hasText?: boolean; + hasTextChoice?: boolean; } function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { @@ -47,16 +49,22 @@ function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { stack.push(node); }); parser.on("closetag", () => { + const node = stack.pop()!; + const parent = stack.at(-1)!; // Markup-compatibility fallbacks repeat their mc:Choice content, e.g. Word text boxes. // Keep one when no Choice carries text, such as a chart with a VML caption. - if (stack.pop()!.name !== "Fallback") return; - const parent = stack.at(-1)!; - if (children(parent).some((child) => child.name === "Choice" && hasText(child))) { + if (node.name === "Fallback" && parent.hasTextChoice) { parent.children.pop(); + return; } + if (!node.hasText) return; + parent.hasText = true; + if (node.name === "Choice") parent.hasTextChoice = true; }); const text = (value: string) => { - stack.at(-1)!.children.push(value); + const node = stack.at(-1)!; + node.children.push(value); + if (value.trim() !== "") node.hasText = true; }; parser.on("text", text); parser.on("cdata", text); @@ -66,8 +74,6 @@ function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { const children = (node: XmlNode) => node.children.filter((child): child is XmlNode => typeof child !== "string"); -const hasText = (node: XmlNode): boolean => - node.children.some((child) => (typeof child === "string" ? child.trim() !== "" : hasText(child))); function* all(node: XmlNode, name: string | readonly string[]): Generator { for (const child of children(node)) { if (typeof name === "string" ? child.name === name : name.includes(child.name)) yield child;