Skip to content

Commit 5b0e59a

Browse files
authored
Merge pull request Expensify#64973 from Expensify/mollfpr-validate-html-chatgpt-translator
[NoQA] Validate HTML string for ChatGPT Translator
2 parents cf56a40 + 6a24488 commit 5b0e59a

4 files changed

Lines changed: 74 additions & 15 deletions

File tree

cspell.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"Charleson",
9999
"Checkmark",
100100
"checkmarked",
101+
"chien",
101102
"Chronos",
102103
"citi",
103104
"clawback",
@@ -136,6 +137,7 @@
136137
"deeplinks",
137138
"delegators",
138139
"delish",
140+
"describedby",
139141
"Deutsch",
140142
"devportal",
141143
"DFOLLY",
@@ -295,6 +297,7 @@
295297
"killall",
296298
"Kowalski",
297299
"Krasoń",
300+
"labelledby",
298301
"Lagertha",
299302
"laggy",
300303
"lastiPhoneLogin",

scripts/utils/Translator/ChatGPTTranslator.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,15 @@ class ChatGPTTranslator extends Translator {
3535
userPrompt: text,
3636
});
3737

38-
if (this.validateTemplatePlaceholders(text, result)) {
38+
if (this.validateTemplatePlaceholders(text, result) && this.validateTemplateHTML(text, result)) {
3939
if (attempt > 0) {
4040
console.log(`🙃 Translation succeeded after ${attempt + 1} attempts`);
4141
}
4242
console.log(`🧠 Translated "${text}" to ${targetLang}: "${result}"`);
4343
return result;
4444
}
4545

46-
console.warn(`⚠️ Translation for "${text}" failed placeholder validation (attempt ${attempt + 1}/${ChatGPTTranslator.MAX_RETRIES + 1})`);
46+
console.warn(`⚠️ Translation for "${text}" failed validation (attempt ${attempt + 1}/${ChatGPTTranslator.MAX_RETRIES + 1})`);
4747

4848
if (attempt === ChatGPTTranslator.MAX_RETRIES) {
4949
console.error(`❌ Final attempt failed placeholder validation. Falling back to original.`);
@@ -63,19 +63,6 @@ class ChatGPTTranslator extends Translator {
6363
// Should never hit this, but fallback just in case
6464
return text;
6565
}
66-
67-
/**
68-
* Validate that placeholders are all present and unchanged before and after translation.
69-
*/
70-
private validateTemplatePlaceholders(original: string, translated: string): boolean {
71-
const extractPlaceholders = (s: string) =>
72-
Array.from(s.matchAll(/\$\{[^}]*}/g))
73-
.map((m) => m[0])
74-
.sort();
75-
const originalSpans = extractPlaceholders(original);
76-
const translatedSpans = extractPlaceholders(translated);
77-
return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans);
78-
}
7966
}
8067

8168
export default ChatGPTTranslator;

scripts/utils/Translator/Translator.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {DomUtils, parseDocument} from 'htmlparser2';
12
import type {TranslationTargetLocale} from '@src/CONST/LOCALES';
23

34
/**
@@ -33,6 +34,58 @@ abstract class Translator {
3334
private trimForLogs(text: string) {
3435
return `${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`;
3536
}
37+
38+
/**
39+
* Validate that placeholders are all present and unchanged before and after translation.
40+
*/
41+
public validateTemplatePlaceholders(original: string, translated: string): boolean {
42+
const extractPlaceholders = (s: string) =>
43+
Array.from(s.matchAll(/\$\{[^}]*}/g))
44+
.map((m) => m[0])
45+
.sort();
46+
const originalSpans = extractPlaceholders(original);
47+
const translatedSpans = extractPlaceholders(translated);
48+
return JSON.stringify(originalSpans) === JSON.stringify(translatedSpans);
49+
}
50+
51+
/**
52+
* Validate that the HTML structure is the same before and after translation.
53+
*/
54+
public validateTemplateHTML(original: string, translated: string): boolean {
55+
// Attributes that are allowed to be translated
56+
const TRANSLATABLE_ATTRIBUTES = new Set(['alt', 'title', 'placeholder', 'aria-label', 'aria-describedby', 'aria-labelledby', 'value']);
57+
58+
const parseHTMLStructure = (s: string) => {
59+
const doc = parseDocument(s);
60+
const elements = DomUtils.getElementsByTagName(() => true, doc, true);
61+
62+
return elements.map((element) => {
63+
const tagName = element.name.toLowerCase();
64+
65+
// Extract attributes, excluding translatable ones
66+
const attributes: string[] = [];
67+
if (element.attribs) {
68+
for (const [attrName, attrValue] of Object.entries(element.attribs ?? {})) {
69+
const normalizedAttrName = attrName.toLowerCase();
70+
if (!TRANSLATABLE_ATTRIBUTES.has(normalizedAttrName)) {
71+
attributes.push(`${normalizedAttrName}="${attrValue ?? ''}"`);
72+
}
73+
}
74+
}
75+
76+
return {
77+
tagName,
78+
attributes: attributes.sort(),
79+
};
80+
});
81+
};
82+
83+
const originalStructure = parseHTMLStructure(original);
84+
const translatedStructure = parseHTMLStructure(translated);
85+
86+
// Compare structures (tag names and non-translatable attributes)
87+
return JSON.stringify(originalStructure) === JSON.stringify(translatedStructure);
88+
}
3689
}
3790

3891
export default Translator;

tests/unit/ChatGPTTranslatorTest.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ describe('ChatGPTTranslator.performTranslation', () => {
1818
const validTranslation = '[it] Hello ${name}!';
1919
const invalidTranslation = '[it] Hello name!'; // missing ${...}
2020

21+
const originalHTML = '<img src="photo.jpg" alt="A dog" class="photo">';
22+
const validHTMLTranslation = '[it] <img src="photo.jpg" alt="Un chien" class="photo">';
23+
const invalidHTMLTranslation = '[it] <img src="different.jpg" alt="Un chien" class="photo">'; // different src
24+
2125
let translator: ChatGPTTranslator;
2226

2327
beforeEach(() => {
@@ -50,4 +54,16 @@ describe('ChatGPTTranslator.performTranslation', () => {
5054
expect(MockedOpenAIUtils.prototype.promptChatCompletions).toHaveBeenCalledTimes(ChatGPTTranslator.MAX_RETRIES + 1);
5155
expect(result).toBe(original);
5256
});
57+
58+
it('retries if translated HTML has incorrect attributes, then succeeds', async () => {
59+
// First attempt returns invalid HTML format, second returns valid
60+
(MockedOpenAIUtils.prototype.promptChatCompletions as jest.Mock).mockResolvedValueOnce(invalidHTMLTranslation).mockResolvedValueOnce(validHTMLTranslation);
61+
62+
// @ts-expect-error TS2445
63+
const result = await translator.performTranslation(targetLang, originalHTML);
64+
65+
// eslint-disable-next-line @typescript-eslint/unbound-method
66+
expect(MockedOpenAIUtils.prototype.promptChatCompletions).toHaveBeenCalledTimes(2);
67+
expect(result).toBe(validHTMLTranslation);
68+
});
5369
});

0 commit comments

Comments
 (0)