From aa455853e3509d520c2adb864e12bd190203e824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Fri, 29 May 2026 20:47:31 +0200 Subject: [PATCH 1/9] Compose multi-value Machinery translations from TM and MT (#2886) For Fluent and MF2-handled formats (Android, Gettext, WebExt, Xcode, Xliff), the Machinery panel only matched on the first input field (via `getPlainMessage()`), so attributes and selector variants were never surfaced. This mirrors Pretranslation's complex string composition in Machinery, adding directly-pasteable composed suggestions alongside the existing results. More details: 1. Parameterize Pretranslation with mt_provider/mt_service_name/ mt_supported, and move entity-walking into `Pretranslation.walk_entity()` so Machinery can reuse the composition pipeline. 2. Add `/machinery-composed/` endpoint that walks the entity, looks up each value in TM, and falls back to the requested MT service for any remaining value. Returns the composed string + the actual mix of services used (TM badge + MT badge for hybrid results). 3. Frontend fires composed requests in parallel with the existing fetches when the entity format can have multiple values. Composed results dedupe through the existing `addResults()` merge. --- pontoon/machinery/tests/test_composed.py | 206 ++++++++++++++++++ pontoon/machinery/urls.py | 5 + pontoon/machinery/views.py | 123 ++++++++++- pontoon/pretranslation/pretranslate.py | 123 +++++++---- translate/src/api/machinery.test.ts | 68 +++++- translate/src/api/machinery.ts | 42 ++++ .../src/context/MachineryTranslations.tsx | 41 ++++ 7 files changed, 566 insertions(+), 42 deletions(-) create mode 100644 pontoon/machinery/tests/test_composed.py diff --git a/pontoon/machinery/tests/test_composed.py b/pontoon/machinery/tests/test_composed.py new file mode 100644 index 0000000000..a13a4ba040 --- /dev/null +++ b/pontoon/machinery/tests/test_composed.py @@ -0,0 +1,206 @@ +import json + +from textwrap import dedent +from unittest.mock import patch + +import pytest + +from django.urls import reverse + +from pontoon.test.factories import ( + EntityFactory, + ResourceFactory, + TranslationMemoryFactory, +) + + +@pytest.fixture +def fluent_resource(project_a): + return ResourceFactory(project=project_a, path="resource.ftl", format="fluent") + + +@pytest.mark.django_db +def test_composed_bad_request(client, locale_a): + """Missing or invalid params should return 400.""" + url = reverse("pontoon.machinery_composed") + + response = client.get(url) + assert response.status_code == 400 + + response = client.get(url, {"entity": "not-a-number", "locale": locale_a.code}) + assert response.status_code == 400 + + response = client.get(url, {"entity": "999999999", "locale": locale_a.code}) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_composed_unsupported_format(client, entity_a, locale_a): + """Non-composable formats (e.g. gettext-without-MF2-context: still allowed) skip cleanly. + + `entity_a` uses the `resource_a` gettext fixture, which IS in COMPOSED_FORMATS, + so we should NOT get an empty response for it. Use a DTD fixture instead — DTD + is not in the composable set, so we expect an empty `{}`. + """ + dtd_resource = ResourceFactory( + project=entity_a.resource.project, path="r.dtd", format="dtd" + ) + dtd_entity = EntityFactory(resource=dtd_resource, string="Hello") + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(dtd_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + assert json.loads(response.content) == {} + + +@pytest.mark.django_db +def test_composed_unknown_service(client, fluent_resource, locale_a): + fluent_entity = EntityFactory(resource=fluent_resource, string="hello = Hello\n") + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "bogus", + }, + ) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_composed_mt_service_requires_auth( + client, fluent_resource, google_translate_locale +): + """MT services require authentication; TM-only is anonymous-friendly.""" + fluent_entity = EntityFactory(resource=fluent_resource, string="hello = Hello\n") + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": google_translate_locale.code, + "service": "google-translate", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_composed_tm_only_full_hit(client, fluent_resource, entity_a, locale_a): + """When every leaf has a TM hit, TM-only returns a composed Fluent string.""" + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + TranslationMemoryFactory.create( + entity=entity_a, source="Click Me", target="TM_value", locale=locale_a + ) + TranslationMemoryFactory.create( + entity=entity_a, + source="Tooltip text", + target="TM_tooltip", + locale=locale_a, + ) + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + body = json.loads(response.content) + assert body["original"] == fluent_string + assert "TM_value" in body["translation"] + assert "TM_tooltip" in body["translation"] + assert body["sources"] == ["translation-memory"] + + +@pytest.mark.django_db +def test_composed_tm_only_partial_returns_empty( + client, fluent_resource, entity_a, locale_a +): + """TM-only mode emits no result when any leaf misses TM.""" + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + # Only one of the two leaves has a TM match. + TranslationMemoryFactory.create( + entity=entity_a, source="Click Me", target="TM_value", locale=locale_a + ) + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + assert json.loads(response.content) == {} + + +@patch("pontoon.machinery.views.get_google_translate_data") +@pytest.mark.django_db +def test_composed_hybrid_tm_and_mt( + gt_mock, + member, + fluent_resource, + entity_a, + google_translate_locale, + google_translate_api_key, +): + """TM hit for one leaf, MT fallback for the other — `sources` reflects the mix.""" + gt_mock.return_value = "MT_tooltip" + + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + TranslationMemoryFactory.create( + entity=entity_a, + source="Click Me", + target="TM_value", + locale=google_translate_locale, + ) + + url = reverse("pontoon.machinery_composed") + response = member.client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": google_translate_locale.code, + "service": "google-translate", + }, + ) + assert response.status_code == 200 + body = json.loads(response.content) + assert "TM_value" in body["translation"] + assert "MT_tooltip" in body["translation"] + assert set(body["sources"]) == {"translation-memory", "google-translate"} diff --git a/pontoon/machinery/urls.py b/pontoon/machinery/urls.py index 60e5f0f8a3..a296274ad5 100644 --- a/pontoon/machinery/urls.py +++ b/pontoon/machinery/urls.py @@ -10,6 +10,11 @@ views.translation_memory, name="pontoon.translation_memory", ), + path( + "machinery-composed/", + views.machinery_composed, + name="pontoon.machinery_composed", + ), path( "concordance-search/", views.concordance_search, diff --git a/pontoon/machinery/views.py b/pontoon/machinery/views.py index 36d77cff1b..8feb520298 100644 --- a/pontoon/machinery/views.py +++ b/pontoon/machinery/views.py @@ -16,18 +16,51 @@ from django.utils.html import strip_tags from django.views.decorators.http import require_POST -from pontoon.base.models import Comment, Entity, Locale, Project, Translation +from pontoon.base.models import Comment, Entity, Locale, Project, Resource, Translation from pontoon.machinery.utils import ( get_concordance_search_data, get_google_translate_data, get_microsoft_translator_data, get_translation_memory_data, ) +from pontoon.pretranslation.pretranslate import Pretranslation from pontoon.terminology.models import Term from .openai_service import OpenAIService +# Map machinery `service` query param to the (mt_provider, locale-support attr) pair +# used by the composed-translation view. `translation-memory` is special-cased to mean +# "TM-only, no MT fallback". +COMPOSED_MT_SERVICES = { + "google-translate": ( + lambda text, locale, preserve_placeables: get_google_translate_data( + text=text, locale=locale, preserve_placeables=preserve_placeables + ), + "google_translate_code", + ), + "microsoft-translator": ( + lambda text, locale, preserve_placeables: get_microsoft_translator_data( + text, locale.ms_translator_code + ), + "ms_translator_code", + ), +} + + +# Formats whose entities can have multiple translatable leaves (Fluent attributes, +# MF2 selector variants). These are the formats Pretranslation handles structurally; +# other formats fall through to single-leaf behavior and don't need a composed result. +COMPOSED_FORMATS = { + Resource.Format.FLUENT, + Resource.Format.ANDROID, + Resource.Format.GETTEXT, + Resource.Format.WEBEXT, + Resource.Format.XCODE, + Resource.Format.XLIFF, +} + + log = logging.getLogger(__name__) @@ -63,6 +96,94 @@ def translation_memory(request): return JsonResponse(data, safe=False) +def machinery_composed(request): + """ + Return a composed multi-value translation for a Fluent / MF2 entity. + + Each translatable leaf (Fluent value/attribute, MF2 variant) is looked up in + Translation Memory; leaves without a 100% TM match fall back to the requested + MT service. Mirrors the Pretranslation pipeline so the Machinery panel can + surface a directly-pasteable composed translation alongside the per-leaf + results. + + Query params: + entity: Entity pk + locale: Locale code + service: one of `translation-memory`, `google-translate`, + `microsoft-translator`. Defaults to `google-translate`. + `translation-memory` disables MT fallback. + """ + try: + entity_pk = int(request.GET["entity"]) + locale = Locale.objects.get(code=request.GET["locale"]) + service = request.GET.get("service", "google-translate") + entity = Entity.objects.select_related("resource").get(pk=entity_pk) + except ( + Entity.DoesNotExist, + Locale.DoesNotExist, + MultiValueDictKeyError, + ValueError, + ) as e: + return JsonResponse( + {"status": False, "message": f"Bad Request: {e}"}, status=400 + ) + + if entity.resource.format not in COMPOSED_FORMATS: + return JsonResponse({}) + + if service == "translation-memory": + mt_provider = None + mt_service_name = "tm" + mt_supported = False + elif service in COMPOSED_MT_SERVICES: + if not request.user.is_authenticated: + return JsonResponse( + {"status": False, "message": "Authentication required"}, status=403 + ) + mt_provider, locale_attr = COMPOSED_MT_SERVICES[service] + mt_service_name = service + mt_supported = bool(getattr(locale, locale_attr, None)) + else: + return JsonResponse( + {"status": False, "message": f"Bad Request: unknown service `{service}`"}, + status=400, + ) + + try: + pt = Pretranslation( + entity, + locale, + preserve_placeables=False, + mt_provider=mt_provider, + mt_service_name=mt_service_name, + mt_supported=mt_supported, + ) + translation = pt.walk_entity() + except ValueError: + # Raised when a leaf has no TM match and MT is unavailable. Compose + # endpoint treats this as "nothing to show" rather than an error. + return JsonResponse({}) + except Exception as e: + return _machinery_error_response(f"Composed machinery ({service})", e) + + if not pt.services or translation == entity.string: + return JsonResponse({}) + + # Preserve insertion order while deduplicating. Map the internal `"tm"` + # identifier to the SourceType the frontend uses for the badge. + sources_used = list( + dict.fromkeys("translation-memory" if s == "tm" else s for s in pt.services) + ) + + return JsonResponse( + { + "original": entity.string, + "translation": translation, + "sources": sources_used, + } + ) + + def concordance_search(request): """Search for translations in the internal translations memory.""" try: diff --git a/pontoon/pretranslation/pretranslate.py b/pontoon/pretranslation/pretranslate.py index 06ce069e0e..55ff3c1d4c 100644 --- a/pontoon/pretranslation/pretranslate.py +++ b/pontoon/pretranslation/pretranslate.py @@ -1,6 +1,6 @@ from copy import deepcopy from re import compile -from typing import Literal +from typing import Callable, Literal from fluent.syntax import FluentSerializer, ast as FTL from fluent.syntax.serializer import serialize_expression @@ -28,6 +28,9 @@ pt_placeholder = compile(r"{ *\$(\d+) *}") +MTProvider = Callable[..., str] + + def get_pretranslation( entity: Entity, locale: Locale, preserve_placeables: bool = False ) -> tuple[str, Literal["gt", "tm"]]: @@ -42,39 +45,8 @@ def get_pretranslation( - a pretranslation of the entity - a pretranslation service identifier, either "gt" or "tm" """ - pt = Pretranslation(entity, locale, preserve_placeables) - if entity.resource.format == Resource.Format.FLUENT: - entry = fluent_parse_entry(entity.string, with_linepos=False) - if entry.value: - pt.message(entry.value) - accesskeys: list[tuple[str, Message]] = [] - for key, prop in entry.properties.items(): - if key.endswith("accesskey"): - accesskeys.append((key, prop)) - else: - pt.message(prop) - for key, prop in accesskeys: - set_accesskey(entry, key, prop) - pt_res = FluentSerializer().serialize_entry( - fluent_astify_entry(entry, escape_syntax=False) - ) - else: - if entity.resource.format in { - Resource.Format.ANDROID, - Resource.Format.GETTEXT, - Resource.Format.WEBEXT, - Resource.Format.XCODE, - Resource.Format.XLIFF, - }: - format = Format.mf2 - msg = parse_message(format, entity.string) - else: - format = None - msg = PatternMessage([entity.string]) - pt.message(msg) - pt_res = serialize_message(format, msg) - + pt_res = pt.walk_entity() pt_service = max(set(pt.services), key=pt.services.count) if pt.services else "tm" return (pt_res, pt_service) @@ -83,10 +55,30 @@ class Pretranslation: format: Format | None locale: Locale preserve_placeables: bool - services: list[Literal["gt", "tm"]] + services: list[str] source: str - def __init__(self, entity: Entity, locale: Locale, preserve_placeables: bool): + def __init__( + self, + entity: Entity, + locale: Locale, + preserve_placeables: bool, + *, + mt_provider: MTProvider | None = None, + mt_service_name: str = "gt", + mt_supported: bool | None = None, + ): + """ + :param mt_provider: Callable invoked when no 100% TM match exists. + Signature: ``(text: str, locale: Locale, preserve_placeables: bool) -> str``. + Defaults to Google Translate. + :param mt_service_name: Identifier recorded in ``self.services`` for each + successful MT call. Defaults to ``"gt"``. + :param mt_supported: If False, MT is skipped and ``ValueError`` is raised + when a leaf can't be served from TM. Defaults to whether the locale + has a ``google_translate_code`` (matching the original behavior). + """ + self.entity = entity match entity.resource.format: case Resource.Format.FLUENT: self.format = Format.fluent @@ -104,6 +96,57 @@ def __init__(self, entity: Entity, locale: Locale, preserve_placeables: bool): self.locale = locale self.preserve_placeables = preserve_placeables self.services = [] + self.mt_provider = mt_provider or get_google_translate_data + self.mt_service_name = mt_service_name + self.mt_supported = ( + mt_supported + if mt_supported is not None + else bool(locale.google_translate_code) + ) + + def walk_entity(self) -> str: + """ + Walk the entity, translating each leaf via TM (then MT fallback), and + return the composed translation string. + + For Fluent, each value, attribute, and selector variant is translated + independently and recomposed. Accesskey attributes are derived from the + translated label after all other leaves are filled. For MF2-handled + formats (Android, Gettext, Webext, Xcode, Xliff), variants are walked + with plural-category handling. All other formats are treated as a + single leaf. + """ + entity = self.entity + if entity.resource.format == Resource.Format.FLUENT: + entry = fluent_parse_entry(entity.string, with_linepos=False) + if entry.value: + self.message(entry.value) + accesskeys: list[tuple[str, Message]] = [] + for key, prop in entry.properties.items(): + if key.endswith("accesskey"): + accesskeys.append((key, prop)) + else: + self.message(prop) + for key, prop in accesskeys: + set_accesskey(entry, key, prop) + return FluentSerializer().serialize_entry( + fluent_astify_entry(entry, escape_syntax=False) + ) + + if entity.resource.format in { + Resource.Format.ANDROID, + Resource.Format.GETTEXT, + Resource.Format.WEBEXT, + Resource.Format.XCODE, + Resource.Format.XLIFF, + }: + format = Format.mf2 + msg = parse_message(format, entity.string) + else: + format = None + msg = PatternMessage([entity.string]) + self.message(msg) + return serialize_message(format, msg) def message(self, msg: Message) -> None: """Modifies `msg`.""" @@ -195,14 +238,14 @@ def pattern(self, pattern: Pattern) -> Pattern: if not has_text: return pattern - if self.locale.google_translate_code: - # Try to fetch from Google Translate - gt_translation = get_google_translate_data( + if self.mt_supported: + # Try to fetch from the configured MT provider (Google by default) + mt_translation = self.mt_provider( text=gt_source, locale=self.locale, preserve_placeables=self.preserve_placeables, ) - self.services.append("gt") + self.services.append(self.mt_service_name) return [ el if idx % 2 == 0 @@ -211,7 +254,7 @@ def pattern(self, pattern: Pattern) -> Pattern: if int(el) < len(placeholders) else "{$" + el + "}" ) - for idx, el in enumerate(pt_placeholder.split(gt_translation)) + for idx, el in enumerate(pt_placeholder.split(mt_translation)) if el != "" ] diff --git a/translate/src/api/machinery.test.ts b/translate/src/api/machinery.test.ts index df518c0d3f..d88e1c7d86 100644 --- a/translate/src/api/machinery.test.ts +++ b/translate/src/api/machinery.test.ts @@ -1,4 +1,6 @@ -import { fetchGPTTransform } from './machinery'; +import type { Locale } from '~/context/Locale'; + +import { fetchComposedMachinery, fetchGPTTransform } from './machinery'; import * as base from './utils/base'; vi.mock('./utils/base', () => ({ @@ -56,3 +58,67 @@ describe('fetchGPTTransform', () => { expect(result).toEqual([]); }); }); + +describe('fetchComposedMachinery', () => { + const GET = vi.mocked(base.GET); + const locale = { code: 'fr' } as Locale; + + it('sends entity, locale, and service params', async () => { + GET.mockResolvedValueOnce({ + original: 'src', + translation: 'composed', + sources: ['translation-memory'], + }); + + await fetchComposedMachinery(42, locale, 'translation-memory'); + + const [url, params] = GET.mock.calls[0] as [string, URLSearchParams]; + expect(url).toBe('/machinery-composed/'); + expect(params.get('entity')).toBe('42'); + expect(params.get('locale')).toBe('fr'); + expect(params.get('service')).toBe('translation-memory'); + }); + + it('returns a MachineryTranslation with the response sources', async () => { + GET.mockResolvedValueOnce({ + original: 'Click Me\n .title = Tip', + translation: 'composed-result', + sources: ['translation-memory', 'google-translate'], + }); + + const result = await fetchComposedMachinery(1, locale, 'google-translate'); + + expect(result).toEqual([ + { + sources: ['translation-memory', 'google-translate'], + original: 'Click Me\n .title = Tip', + translation: 'composed-result', + }, + ]); + }); + + it('returns empty array when response is empty', async () => { + GET.mockResolvedValueOnce({}); + const result = await fetchComposedMachinery( + 1, + locale, + 'translation-memory', + ); + expect(result).toEqual([]); + }); + + it('falls back to the requested service when sources is missing', async () => { + GET.mockResolvedValueOnce({ + original: 'src', + translation: 'composed', + }); + + const result = await fetchComposedMachinery( + 1, + locale, + 'microsoft-translator', + ); + + expect(result[0].sources).toEqual(['microsoft-translator']); + }); +}); diff --git a/translate/src/api/machinery.ts b/translate/src/api/machinery.ts index 1e25c82027..9ddb4a1365 100644 --- a/translate/src/api/machinery.ts +++ b/translate/src/api/machinery.ts @@ -131,6 +131,48 @@ export async function fetchTranslationMemory( : []; } +/** + * Return a composed multi-value translation for a Fluent / MF2 entity. + * + * Each translatable leaf (Fluent value/attribute, MF2 variant) is looked up + * in Translation Memory, falling back to the requested MT service when no + * exact TM match exists. Use `service: 'translation-memory'` to disable the + * MT fallback and only emit a result when every leaf has a TM hit. + * + * Returns an empty array when the entity isn't a composable format or when + * no composed translation can be produced (e.g. MT unavailable for locale). + */ +export async function fetchComposedMachinery( + pk: number, + locale: Locale, + service: 'translation-memory' | 'google-translate' | 'microsoft-translator', +): Promise { + const url = '/machinery-composed/'; + const params = { + entity: String(pk), + locale: locale.code, + service, + }; + + const result = (await GET_(url, params)) as { + original?: string; + translation?: string; + sources?: string[]; + }; + + if (!result || !result.translation || !result.original) { + return []; + } + + return [ + { + sources: (result.sources ?? [service]) as SourceType[], + original: result.original, + translation: result.translation, + }, + ]; +} + /** * Return translation by Google Translate. */ diff --git a/translate/src/context/MachineryTranslations.tsx b/translate/src/context/MachineryTranslations.tsx index b4ee81b239..3bbfcfa74f 100644 --- a/translate/src/context/MachineryTranslations.tsx +++ b/translate/src/context/MachineryTranslations.tsx @@ -3,6 +3,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { abortMachineryRequests, fetchCaighdeanTranslation, + fetchComposedMachinery, fetchGoogleTranslation, fetchMicrosoftTranslation, fetchTranslationMemory, @@ -36,6 +37,19 @@ const sortByQuality = ( { quality: b }: MachineryTranslation, ) => (!a ? 1 : !b ? -1 : a > b ? -1 : a < b ? 1 : 0); +// Formats whose entities can have multiple translatable leaves (Fluent +// attributes, MF2 selector variants). For these we request a composed +// multi-value translation in addition to the per-leaf matches. Mirrors +// `COMPOSED_FORMATS` in pontoon/machinery/views.py. +const COMPOSED_FORMATS = new Set([ + 'fluent', + 'android', + 'gettext', + 'webext', + 'xcode', + 'xliff', +]); + export function MachineryProvider({ children, }: { @@ -102,12 +116,25 @@ export function MachineryProvider({ setFetching(true); const promises: Promise[] = []; + // Composed multi-value translations are emitted only for entity-driven + // navigation (not concordance search) and only for formats that can + // have multiple translatable leaves. + const wantsComposed = !!pk && COMPOSED_FORMATS.has(format); + if (pk) { promises.push( fetchTranslationMemory(plain, locale, pk).then(addResults), ); } + if (wantsComposed) { + promises.push( + fetchComposedMachinery(pk!, locale, 'translation-memory').then( + addResults, + ), + ); + } + // Only make requests to paid services if user is authenticated if (isAuthenticated) { const root = document.getElementById('root'); @@ -119,12 +146,26 @@ export function MachineryProvider({ if (isGoogleTranslateSupported && locale.googleTranslateCode) { promises.push(fetchGoogleTranslation(plain, locale).then(addResults)); + if (wantsComposed) { + promises.push( + fetchComposedMachinery(pk!, locale, 'google-translate').then( + addResults, + ), + ); + } } if (isMicrosoftTranslatorSupported && locale.msTranslatorCode) { promises.push( fetchMicrosoftTranslation(plain, locale).then(addResults), ); + if (wantsComposed) { + promises.push( + fetchComposedMachinery(pk!, locale, 'microsoft-translator').then( + addResults, + ), + ); + } } } From bd860462e84bc3450aac6e03b18f2b65e1e246d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 1 Jun 2026 20:15:19 +0200 Subject: [PATCH 2/9] 1. Skip composed requests for single-field entities. Gate the request on the entity having more than one translatable input, reusing the editor's field-counting logic. 2. Surface a quality badge. When every value is a 100% TM match, the composed string is a perfect TM match, so return quality 100 and pass it through to the panel. 3. Render composed (multi-value) suggestions as labeled fields, the same representation as the original string panel. --- pontoon/machinery/tests/test_composed.py | 4 + pontoon/machinery/views.py | 21 ++- translate/src/api/machinery.test.ts | 19 +++ translate/src/api/machinery.ts | 7 + .../src/context/MachineryTranslations.tsx | 16 +- .../components/MachineryTranslation.css | 23 +++ .../components/MachineryTranslation.test.js | 36 +++++ .../components/MachineryTranslation.tsx | 140 +++++++++++++++--- 8 files changed, 233 insertions(+), 33 deletions(-) diff --git a/pontoon/machinery/tests/test_composed.py b/pontoon/machinery/tests/test_composed.py index a13a4ba040..3d4ac0c7da 100644 --- a/pontoon/machinery/tests/test_composed.py +++ b/pontoon/machinery/tests/test_composed.py @@ -129,6 +129,8 @@ def test_composed_tm_only_full_hit(client, fluent_resource, entity_a, locale_a): assert "TM_value" in body["translation"] assert "TM_tooltip" in body["translation"] assert body["sources"] == ["translation-memory"] + # Every leaf is a 100% TM match, so the composed result is a full TM match. + assert body["quality"] == 100 @pytest.mark.django_db @@ -204,3 +206,5 @@ def test_composed_hybrid_tm_and_mt( assert "TM_value" in body["translation"] assert "MT_tooltip" in body["translation"] assert set(body["sources"]) == {"translation-memory", "google-translate"} + # MT-assisted results have no meaningful aggregate quality score. + assert "quality" not in body diff --git a/pontoon/machinery/views.py b/pontoon/machinery/views.py index 8feb520298..05b2e8f624 100644 --- a/pontoon/machinery/views.py +++ b/pontoon/machinery/views.py @@ -175,13 +175,20 @@ def machinery_composed(request): dict.fromkeys("translation-memory" if s == "tm" else s for s in pt.services) ) - return JsonResponse( - { - "original": entity.string, - "translation": translation, - "sources": sources_used, - } - ) + response = { + "original": entity.string, + "translation": translation, + "sources": sources_used, + } + + # When every leaf came from a 100% TM match (`pattern()` only accepts exact + # source matches from TM), the composed string is a complete TM match — give + # it the same quality badge regular TM matches get. Hybrid results that fall + # back to MT for any leaf have no meaningful aggregate score. + if set(pt.services) == {"tm"}: + response["quality"] = 100 + + return JsonResponse(response) def concordance_search(request): diff --git a/translate/src/api/machinery.test.ts b/translate/src/api/machinery.test.ts index d88e1c7d86..bfdd131ec6 100644 --- a/translate/src/api/machinery.test.ts +++ b/translate/src/api/machinery.test.ts @@ -93,10 +93,29 @@ describe('fetchComposedMachinery', () => { sources: ['translation-memory', 'google-translate'], original: 'Click Me\n .title = Tip', translation: 'composed-result', + composed: true, }, ]); }); + it('passes through the quality of a full TM match', async () => { + GET.mockResolvedValueOnce({ + original: 'Click Me\n .title = Tip', + translation: 'composed-result', + sources: ['translation-memory'], + quality: 100, + }); + + const result = await fetchComposedMachinery( + 1, + locale, + 'translation-memory', + ); + + expect(result[0].quality).toBe(100); + expect(result[0].composed).toBe(true); + }); + it('returns empty array when response is empty', async () => { GET.mockResolvedValueOnce({}); const result = await fetchComposedMachinery( diff --git a/translate/src/api/machinery.ts b/translate/src/api/machinery.ts index 9ddb4a1365..16630b8932 100644 --- a/translate/src/api/machinery.ts +++ b/translate/src/api/machinery.ts @@ -22,6 +22,10 @@ export type MachineryTranslation = { original: string; translation: string; quality?: number; + // Set for `/machinery-composed/` results, whose `original` and `translation` + // are full entry sources (Fluent attributes, MF2 variants) rather than plain + // strings — the Machinery panel renders these in a rich, multi-field view. + composed?: boolean; projects?: { name: string; slug: string; @@ -158,6 +162,7 @@ export async function fetchComposedMachinery( original?: string; translation?: string; sources?: string[]; + quality?: number; }; if (!result || !result.translation || !result.original) { @@ -169,6 +174,8 @@ export async function fetchComposedMachinery( sources: (result.sources ?? [service]) as SourceType[], original: result.original, translation: result.translation, + quality: result.quality, + composed: true, }, ]; } diff --git a/translate/src/context/MachineryTranslations.tsx b/translate/src/context/MachineryTranslations.tsx index 3bbfcfa74f..005fd2bc68 100644 --- a/translate/src/context/MachineryTranslations.tsx +++ b/translate/src/context/MachineryTranslations.tsx @@ -11,7 +11,7 @@ import { } from '~/api/machinery'; import { USER } from '~/modules/user'; import { useAppSelector } from '~/hooks'; -import { getPlainMessage } from '~/utils/message'; +import { editMessageEntry, getPlainMessage, parseEntry } from '~/utils/message'; import { EntityView } from './EntityView'; import { Locale } from './Locale'; @@ -50,6 +50,15 @@ const COMPOSED_FORMATS = new Set([ 'xliff', ]); +// A composed translation is only meaningful when the entity has more than one +// translatable leaf (Fluent attributes, MF2 selector variants). For a simple +// single-field entity the composed result would just duplicate the per-leaf TM +// or MT match, so we skip the request entirely. +function hasMultipleFields(original: string, format: string): boolean { + const entry = parseEntry(format, original); + return !!entry && editMessageEntry(entry).length > 1; +} + export function MachineryProvider({ children, }: { @@ -119,7 +128,10 @@ export function MachineryProvider({ // Composed multi-value translations are emitted only for entity-driven // navigation (not concordance search) and only for formats that can // have multiple translatable leaves. - const wantsComposed = !!pk && COMPOSED_FORMATS.has(format); + const wantsComposed = + !!pk && + COMPOSED_FORMATS.has(format) && + hasMultipleFields(entity.original, format); if (pk) { promises.push( diff --git a/translate/src/modules/machinery/components/MachineryTranslation.css b/translate/src/modules/machinery/components/MachineryTranslation.css index b1c1aba54d..df2ae82cb6 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.css +++ b/translate/src/modules/machinery/components/MachineryTranslation.css @@ -147,3 +147,26 @@ .machinery .translation p.suggestion .fa-spin { color: var(--translation-secondary-color); } + +/* Composed multi-value suggestions: rendered as labeled fields, mirroring the + original string panel (.fluent-rich-string base styles are shared). */ +.machinery .translation table.fluent-rich-string { + padding: 0; + margin: 0; + color: var(--translation-color); +} + +.machinery .translation table.fluent-rich-string.original { + color: var(--translation-secondary-color); + margin-bottom: 4px; +} + +.machinery .translation table.fluent-rich-string td { + vertical-align: top; +} + +.machinery .translation table.fluent-rich-string td > span { + display: block; + line-height: 22px; + white-space: pre-wrap; +} diff --git a/translate/src/modules/machinery/components/MachineryTranslation.test.js b/translate/src/modules/machinery/components/MachineryTranslation.test.js index 58e62b8494..93ef2bcf9c 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.test.js +++ b/translate/src/modules/machinery/components/MachineryTranslation.test.js @@ -1,3 +1,6 @@ +import React from 'react'; + +import { EntityView } from '~/context/EntityView'; import { createDefaultUser, createReduxStore, @@ -65,4 +68,37 @@ describe('', () => { expect(container.querySelector('.quality')).toBeInTheDocument(); expect(container.querySelector('.quality')).toHaveTextContent('100%'); }); + + it('renders a composed multi-field translation as a rich table', () => { + const translation = { + sources: ['translation-memory'], + composed: true, + quality: 100, + original: 'button = Click Me\n .title = Tooltip\n', + translation: 'button = Cliquez\n .title = Infobulle\n', + }; + const store = createReduxStore(); + const Wrapped = (props) => + React.createElement( + EntityView.Provider, + { value: { entity: { format: 'fluent' } } }, + React.createElement(MachineryTranslationComponent, props), + ); + const { container } = mountComponentWithStore(Wrapped, store, { + translation, + }); + createDefaultUser(store); + + // Each leaf (value + attribute) is shown as a labeled row, on both the + // original and the suggestion side. + const original = container.querySelector('.fluent-rich-string.original'); + const suggestion = container.querySelector( + '.fluent-rich-string.suggestion', + ); + expect(original).toBeInTheDocument(); + expect(suggestion).toBeInTheDocument(); + expect(original.querySelectorAll('tr')).toHaveLength(2); + expect(suggestion.textContent).toContain('Cliquez'); + expect(suggestion.textContent).toContain('Infobulle'); + }); }); diff --git a/translate/src/modules/machinery/components/MachineryTranslation.tsx b/translate/src/modules/machinery/components/MachineryTranslation.tsx index 20aed69f57..1027089661 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.tsx +++ b/translate/src/modules/machinery/components/MachineryTranslation.tsx @@ -4,11 +4,17 @@ import React, { useCallback, useContext, useEffect, useRef } from 'react'; import type { MachineryTranslation, SourceType } from '~/api/machinery'; import { logUXAction } from '~/api/uxaction'; -import { EditorActions } from '~/context/Editor'; +import { EditorActions, EditorField } from '~/context/Editor'; +import { EntityView } from '~/context/EntityView'; import { HelperSelection } from '~/context/HelperSelection'; import { Locale } from '~/context/Locale'; import { GenericTranslation } from '~/modules/translation'; import { useReadonlyEditor } from '~/hooks/useReadonlyEditor'; +import { + editMessageEntry, + parseEntry, + requiresSourceView, +} from '~/utils/message'; import { ConcordanceSearch } from './ConcordanceSearch'; import { MachineryTranslationSource } from './MachineryTranslationSource'; @@ -110,10 +116,22 @@ function MachineryTranslationSuggestion({ translation: MachineryTranslation; }) { const { code, direction, script } = useContext(Locale); + const { entity } = useContext(EntityView); const getLLMTranslationState = useLLMTranslation(); const { llmTranslation, loading } = getLLMTranslationState(translation); + // Composed suggestions carry full entry sources (Fluent attributes, MF2 + // variants). Render them as labeled fields — the same representation as the + // original string panel — instead of a single raw block of syntax. + const originalFields = translation.composed + ? richFields(entity.format, translation.original) + : null; + const suggestionFields = translation.composed + ? richFields(entity.format, translation.translation) + : null; + const isRich = originalFields !== null && suggestionFields !== null; + return ( <>
@@ -123,30 +141,104 @@ function MachineryTranslationSuggestion({
-

- -

-

- {loading ? ( - - ) : ( - + + - )} -

+ + ) : ( + <> +

+ +

+

+ {loading ? ( + + ) : ( + + )} +

+ + )} ); } + +/** + * Parse a composed entry source into its editable fields, or return `null` when + * it can't be shown as a rich multi-field view (parse error, source-view-only + * entry, or a single field — in which case the plain rendering is used). + */ +function richFields(format: string, content: string): EditorField[] | null { + const entry = parseEntry(format, content); + if (!entry || requiresSourceView(entry)) { + return null; + } + const fields = editMessageEntry(entry); + return fields.length > 1 ? fields : null; +} + +/** Render a parsed message as a labeled table, mirroring the original string panel. */ +function RichMessage({ + className, + fields, + dir, + lang, + script, +}: { + className: string; + fields: EditorField[]; + dir?: string; + lang?: string; + script?: string; +}): React.ReactElement<'table'> { + return ( + + + {fields.map(({ handle, id, labels }) => ( + + + + + ))} + +
+ + + + + +
+ ); +} From 7eb8b54ca266529210f40cdb9669481dda913426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 1 Jun 2026 20:45:50 +0200 Subject: [PATCH 3/9] Parse composed Machinery suggestions and distribute their values across all input fields, reusing the field-building logic that is already used by the History panel. The plain message is recorded as `machinery.translation` so that source attribution still matches the saved translation on submit. --- translate/src/context/Editor.test.jsx | 40 +++++++++ translate/src/context/Editor.tsx | 87 +++++++++++++------ .../components/MachineryTranslation.tsx | 5 +- .../utils/editFieldShortcuts.ts | 10 ++- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/translate/src/context/Editor.test.jsx b/translate/src/context/Editor.test.jsx index 75a391b705..9b52893993 100644 --- a/translate/src/context/Editor.test.jsx +++ b/translate/src/context/Editor.test.jsx @@ -482,6 +482,46 @@ describe('', () => { }); }); + it('distributes a composed helper entry across all fields', () => { + let editor, result, actions; + const Spy = () => { + editor = useContext(EditorData); + result = useContext(EditorResult); + actions = useContext(EditorActions); + return null; + }; + mountSpy(Spy, 'fluent', `key = VALUE\n .title = TITLE\n`); + + const source = ftl` + key = COMPOSED + .title = COMPOSED_TITLE + `; + act(() => + actions.setEditorFromHelpers(source, ['translation-memory'], true, true), + ); + + // The full entry source is spread across the value and attribute fields, + // not dumped into the first field. + expect(editor).toMatchObject({ + sourceView: false, + fields: [ + { + handle: { current: { value: 'COMPOSED' } }, + name: '', + }, + { + handle: { current: { value: 'COMPOSED_TITLE' } }, + name: 'title', + }, + ], + machinery: { manual: true, sources: ['translation-memory'] }, + }); + expect(result).toMatchObject([ + { name: '', value: 'COMPOSED' }, + { name: 'title', value: 'COMPOSED_TITLE' }, + ]); + }); + it('toggles Fluent source view', () => { let editor, result, actions; const Spy = () => { diff --git a/translate/src/context/Editor.tsx b/translate/src/context/Editor.tsx index 55c099f3cb..afb5d322e1 100644 --- a/translate/src/context/Editor.tsx +++ b/translate/src/context/Editor.tsx @@ -16,6 +16,7 @@ import { editSource, requiresSourceView, getEmptyMessageEntry, + getPlainMessage, MessageEntry, parseEntry, serializeEntry, @@ -94,11 +95,17 @@ export type EditorActions = { /** If `format: 'fluent'`, must be called with the source of a full entry */ setEditorFromHistory(value: string): void; - /** @param manual Set `true` when value set due to direct user action */ + /** + * @param manual Set `true` when value set due to direct user action + * @param entry Set `true` when `value` is the source of a full entry (e.g. a + * composed Machinery suggestion) that should be parsed and distributed + * across all fields rather than inserted into the focused field. + */ setEditorFromHelpers( value: string, sources: SourceType[], manual: boolean, + entry?: boolean, ): void; setEditorSelection(content: string): void; @@ -173,6 +180,39 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { if (readonly) { return initEditorActions; } + + // Parse a full entry source and distribute its leaves across all editor + // fields, falling back to a raw source-view field when it can't be parsed. + // Shared by history restores and composed Machinery copies. + const distributeEntrySource = ( + prev: EditorData, + str: string, + ): EditorData => { + const next = { ...prev }; + if (specialFormats.has(format)) { + const entry = parseEntry(format, str); + if (entry) { + next.base = entry; + } else if (format !== 'fluent') { + return prev; + } + if (entry && !requiresSourceView(entry)) { + next.fields = prev.sourceView + ? editSource(entry) + : editMessageEntry(entry); + } else { + next.fields = editSource(str); + next.sourceView = true; + } + } else { + next.fields = editMessageEntry(prev.initial); + next.fields[0].handle.current.setValue(str); + } + next.focusField.current = next.fields[0]; + setResult(buildMessageEntry(next.base, next.fields)); + return next; + }; + return { clearEditor() { setState((state) => { @@ -187,8 +227,24 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { setEditorBusy: (busy) => setState((prev) => (busy === prev.busy ? prev : { ...prev, busy })), - setEditorFromHelpers: (str, sources, manual) => + setEditorFromHelpers: (str, sources, manual, entry) => setState((prev) => { + // Composed suggestions carry a full entry source. Outside source view + // we must parse it and distribute the leaves across all fields rather + // than dumping the raw syntax into the focused field. Record the plain + // message as `machinery.translation` so source attribution still + // matches the saved translation (see `useSendTranslation`). + if (entry && !prev.sourceView) { + const next = distributeEntrySource(prev, str); + return { + ...next, + machinery: { + manual, + translation: getPlainMessage(str, format), + sources, + }, + }; + } const { fields, focusField, sourceView } = prev; const field = focusField.current ?? fields[0]; field.handle.current.setValue(str); @@ -207,32 +263,7 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { }), setEditorFromHistory: (str) => - setState((prev) => { - const next = { ...prev }; - if (specialFormats.has(format)) { - const entry = parseEntry(format, str); - if (entry) { - next.base = entry; - } else if (format !== 'fluent') { - return prev; - } - if (entry && !requiresSourceView(entry)) { - next.fields = prev.sourceView - ? editSource(entry) - : editMessageEntry(entry); - } else { - next.fields = editSource(str); - next.sourceView = true; - } - } else { - next.fields = editMessageEntry(prev.initial); - next.fields[0].handle.current.setValue(str); - } - next.focusField.current = next.fields[0]; - const result = buildMessageEntry(next.base, next.fields); - setResult(result); - return next; - }), + setState((prev) => distributeEntrySource(prev, str)), setEditorSelection: (content) => setState((state) => { diff --git a/translate/src/modules/machinery/components/MachineryTranslation.tsx b/translate/src/modules/machinery/components/MachineryTranslation.tsx index 1027089661..dc5fe3d9e5 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.tsx +++ b/translate/src/modules/machinery/components/MachineryTranslation.tsx @@ -57,7 +57,10 @@ export function MachineryTranslationComponent({ const sources: SourceType[] = llmTranslation ? ['gpt-transform'] : translation.sources; - setEditorFromHelpers(content, sources, true); + // A composed suggestion is a full entry source (the LLM transform output + // is a plain string, so it isn't), to be spread across all fields. + const isEntry = !llmTranslation && !!translation.composed; + setEditorFromHelpers(content, sources, true, isEntry); if (llmTranslation) { logUXAction('LLM Translation Copied', 'LLM Feature Adoption', { action: 'Copy LLM Translation', diff --git a/translate/src/modules/translationform/utils/editFieldShortcuts.ts b/translate/src/modules/translationform/utils/editFieldShortcuts.ts index 8acff5c029..ff6a0f871f 100644 --- a/translate/src/modules/translationform/utils/editFieldShortcuts.ts +++ b/translate/src/modules/translationform/utils/editFieldShortcuts.ts @@ -123,7 +123,15 @@ export function useHandleCtrlShiftArrow(): ( const llmState = getLLMTranslationState(machineryTranslations[nextIdx]); const updatedTranslation = llmState.llmTranslation || translationObj.translation; - setEditorFromHelpers(updatedTranslation, translationObj.sources, true); + // A composed suggestion is a full entry source to spread across all + // fields; the LLM transform output is a plain string, so it isn't. + const isEntry = !llmState.llmTranslation && !!translationObj.composed; + setEditorFromHelpers( + updatedTranslation, + translationObj.sources, + true, + isEntry, + ); if (llmState.llmTranslation) { logUXAction( From a73154ed4700754fe754e1dce4d706d7b489fa4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 1 Jun 2026 22:53:55 +0200 Subject: [PATCH 4/9] Regular TM matches exclude the entity's own TM entries so a translated string isn't suggested back to itself. The composed path didn't, so a composed TM result could be reconstructed from the entity's own translation after it was translated. Add an opt-in `exclude_entity` flag to Pretranslation that excludes the entity's own TM entries from per-value lookups, and enable it from the Machinery composed view. A value that can only be served by the entity's own translation then has no TM match, so a TM-only composition relying on it is no longer produced. Pretranslation behavior is unchanged. --- pontoon/machinery/tests/test_composed.py | 40 ++++++++++++++++++++++++ pontoon/machinery/views.py | 1 + pontoon/pretranslation/pretranslate.py | 18 ++++++++--- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/pontoon/machinery/tests/test_composed.py b/pontoon/machinery/tests/test_composed.py index 3d4ac0c7da..73087bde13 100644 --- a/pontoon/machinery/tests/test_composed.py +++ b/pontoon/machinery/tests/test_composed.py @@ -164,6 +164,46 @@ def test_composed_tm_only_partial_returns_empty( assert json.loads(response.content) == {} +@pytest.mark.django_db +def test_composed_tm_excludes_current_entity(client, fluent_resource, locale_a): + """TM matches belonging to the composed entity itself are excluded. + + Once the entity is translated its leaves become TM entries; like regular TM + matches, those must not be suggested back, so a TM-only composition that + relies solely on them produces no result. + """ + fluent_string = dedent( + """ + button = Click Me + .title = Tooltip text + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string) + + # Both leaves only match TM entries that belong to this same entity. + TranslationMemoryFactory.create( + entity=fluent_entity, source="Click Me", target="TM_value", locale=locale_a + ) + TranslationMemoryFactory.create( + entity=fluent_entity, + source="Tooltip text", + target="TM_tooltip", + locale=locale_a, + ) + + url = reverse("pontoon.machinery_composed") + response = client.get( + url, + { + "entity": str(fluent_entity.pk), + "locale": locale_a.code, + "service": "translation-memory", + }, + ) + assert response.status_code == 200 + assert json.loads(response.content) == {} + + @patch("pontoon.machinery.views.get_google_translate_data") @pytest.mark.django_db def test_composed_hybrid_tm_and_mt( diff --git a/pontoon/machinery/views.py b/pontoon/machinery/views.py index 05b2e8f624..c02f6e379b 100644 --- a/pontoon/machinery/views.py +++ b/pontoon/machinery/views.py @@ -157,6 +157,7 @@ def machinery_composed(request): mt_provider=mt_provider, mt_service_name=mt_service_name, mt_supported=mt_supported, + exclude_entity=True, ) translation = pt.walk_entity() except ValueError: diff --git a/pontoon/pretranslation/pretranslate.py b/pontoon/pretranslation/pretranslate.py index 55ff3c1d4c..40de81db0e 100644 --- a/pontoon/pretranslation/pretranslate.py +++ b/pontoon/pretranslation/pretranslate.py @@ -67,6 +67,7 @@ def __init__( mt_provider: MTProvider | None = None, mt_service_name: str = "gt", mt_supported: bool | None = None, + exclude_entity: bool = False, ): """ :param mt_provider: Callable invoked when no 100% TM match exists. @@ -77,6 +78,11 @@ def __init__( :param mt_supported: If False, MT is skipped and ``ValueError`` is raised when a leaf can't be served from TM. Defaults to whether the locale has a ``google_translate_code`` (matching the original behavior). + :param exclude_entity: If True, the entity's own TM entries are excluded + from per-leaf TM lookups, matching ``get_translation_memory_data``. + A leaf that can only be served by the entity's own translation then + has no TM match, so a composed result is not reconstructed from the + current entity. Defaults to False. """ self.entity = entity match entity.resource.format: @@ -103,6 +109,7 @@ def __init__( if mt_supported is not None else bool(locale.google_translate_code) ) + self.exclude_entity = exclude_entity def walk_entity(self) -> str: """ @@ -206,11 +213,14 @@ def pattern(self, pattern: Pattern) -> Pattern: ) if not tm_source or tm_source.isspace(): return pattern - tm_q100 = list( - TranslationMemoryEntry.objects.filter( - locale=self.locale, source=tm_source - ).values_list("target", flat=True) + tm_entries = TranslationMemoryEntry.objects.filter( + locale=self.locale, source=tm_source ) + if self.exclude_entity: + # Mirror get_translation_memory_data(): never suggest the entity's + # own translation back to itself. + tm_entries = tm_entries.exclude(entity=self.entity) + tm_q100 = list(tm_entries.values_list("target", flat=True)) if tm_q100: tm_best = max(set(tm_q100), key=tm_q100.count) self.services.append("tm") From 670d3b711e1313549a527821ba0c2572f5d0450b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Wed, 17 Jun 2026 17:50:41 +0200 Subject: [PATCH 5/9] Make sure the composed Machinery suggestions are always at the top --- translate/src/context/MachineryTranslations.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/translate/src/context/MachineryTranslations.tsx b/translate/src/context/MachineryTranslations.tsx index 005fd2bc68..c6a0d4031f 100644 --- a/translate/src/context/MachineryTranslations.tsx +++ b/translate/src/context/MachineryTranslations.tsx @@ -32,10 +32,16 @@ const initTranslations: MachineryTranslations = { export const MachineryTranslations = createContext(initTranslations); -const sortByQuality = ( - { quality: a }: MachineryTranslation, - { quality: b }: MachineryTranslation, -) => (!a ? 1 : !b ? -1 : a > b ? -1 : a < b ? 1 : 0); +// Composed multi-value suggestions always sort to the top, ahead of the +// per-leaf matches; within each group we sort by descending quality. +const sortByQuality = (a: MachineryTranslation, b: MachineryTranslation) => { + if (a.composed !== b.composed) { + return a.composed ? -1 : 1; + } + const { quality: qa } = a; + const { quality: qb } = b; + return !qa ? 1 : !qb ? -1 : qa > qb ? -1 : qa < qb ? 1 : 0; +}; // Formats whose entities can have multiple translatable leaves (Fluent // attributes, MF2 selector variants). For these we request a composed From 2f2fbbeb20a44df92a536c99e5242e77c26618eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Wed, 17 Jun 2026 18:07:09 +0200 Subject: [PATCH 6/9] Sync distributed entry values to live editor Re-applying a composed Machinery suggestion (or restoring history) after editing a field took two clicks: the first did nothing. Typing updates only CodeMirror's internal doc and EditorResult, not EditorData.state.fields, so TranslationForm doesn't re-render and each EditField keeps a stale `defaultValue`. EditField re-syncs its document only in `useEffect(() => setValue(defaultValue), [defaultValue])`. distributeEntrySource builds new fields with placeholder handles while the on-screen editors stay bound, via their React key, to the previous fields' live handles. The re-applied value for the edited field equals its stale `defaultValue`, so the effect doesn't fire and the field isn't updated. A later re-render refreshes `defaultValue`, which is why the second click works. Push the distributed values straight into the live handles, matched by field id, the same way clearEditor does, so one click suffices. --- translate/src/context/Editor.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/translate/src/context/Editor.tsx b/translate/src/context/Editor.tsx index afb5d322e1..3eb96d0d0f 100644 --- a/translate/src/context/Editor.tsx +++ b/translate/src/context/Editor.tsx @@ -208,6 +208,17 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { next.fields = editMessageEntry(prev.initial); next.fields[0].handle.current.setValue(str); } + // `next.fields` carry placeholder handles, but the on-screen editors stay + // bound (via their React key) to `prev.fields`' live handles. EditField + // only re-syncs when its `defaultValue` string changes, so re-applying a + // value that equals a stale `defaultValue` — e.g. restoring a composed + // suggestion after editing one field — would otherwise leave that field + // untouched until a second click. Push the values into the live handles + // directly, matching by field id, like `clearEditor` does. + for (const field of next.fields) { + const live = prev.fields.find((f) => f.id === field.id); + live?.handle.current.setValue(field.handle.current.value); + } next.focusField.current = next.fields[0]; setResult(buildMessageEntry(next.base, next.fields)); return next; From b241ca89e50dd34d045de1da47c73f694f6b7d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Wed, 17 Jun 2026 18:15:11 +0200 Subject: [PATCH 7/9] Fix failing test --- translate/src/context/Editor.test.jsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/translate/src/context/Editor.test.jsx b/translate/src/context/Editor.test.jsx index 9b52893993..231fcceccf 100644 --- a/translate/src/context/Editor.test.jsx +++ b/translate/src/context/Editor.test.jsx @@ -516,10 +516,14 @@ describe('', () => { ], machinery: { manual: true, sources: ['translation-memory'] }, }); - expect(result).toMatchObject([ - { name: '', value: 'COMPOSED' }, - { name: 'title', value: 'COMPOSED_TITLE' }, - ]); + // The editor result is the rebuilt entry, with the composed value and + // attribute distributed into their respective patterns. + expect(result).toEqual({ + format: 'fluent', + id: 'key', + value: ['COMPOSED'], + attributes: new Map([['title', ['COMPOSED_TITLE']]]), + }); }); it('toggles Fluent source view', () => { From 641b22e949826c9973e702314f965d452d493752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Thu, 18 Jun 2026 13:44:00 +0200 Subject: [PATCH 8/9] Disable AI refinement for composed Machinery suggestions The "Refine using AI" dropdown doesn't work on composed (multi-field/ plural) suggestions: the loader never shows, the refined result never updates the UI, and copying dumps the raw Fluent source into the first field. The backend /gpt-transform/ endpoint also refines a single string, so it can't preserve the entry structure (e.g. returns 2 plural forms instead of 4). Hide the dropdown for composed suggestions so they behave like a plain Google Translate source. Proper composed support is left as a follow-up. --- .../machinery/components/source/GoogleTranslation.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/translate/src/modules/machinery/components/source/GoogleTranslation.tsx b/translate/src/modules/machinery/components/source/GoogleTranslation.tsx index f3b35018da..02c3b39a36 100644 --- a/translate/src/modules/machinery/components/source/GoogleTranslation.tsx +++ b/translate/src/modules/machinery/components/source/GoogleTranslation.tsx @@ -67,7 +67,11 @@ export function GoogleTranslation({ ); - return isOpenAIChatGPTSupported ? ( + // AI refinement isn't supported for composed (multi-field/plural) + // suggestions: the backend refines a single string, so it can't preserve the + // entry structure, and the rich rendering can't show the refined result. Show + // the plain source label instead. See follow-up to add composed support. + return isOpenAIChatGPTSupported && !translation.composed ? (
  • Date: Thu, 18 Jun 2026 15:38:05 +0200 Subject: [PATCH 9/9] Add separator between Machinery source titles When a suggestion combines multiple sources (e.g. GOOGLE TRANSLATE and TRANSLATION MEMORY), the source titles ran together with no separator. --- .../machinery/components/MachineryTranslation.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/translate/src/modules/machinery/components/MachineryTranslation.css b/translate/src/modules/machinery/components/MachineryTranslation.css index df2ae82cb6..49179313f4 100644 --- a/translate/src/modules/machinery/components/MachineryTranslation.css +++ b/translate/src/modules/machinery/components/MachineryTranslation.css @@ -44,6 +44,15 @@ padding-right: 3px; } +.machinery + .translation + > header + .sources:not(.projects) + > li:not(:first-child)::before { + content: '•'; + padding-right: 3px; +} + .machinery .translation > header div { cursor: default; }