Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d50f1da
Compose multi-value Machinery translations from TM and MT (#2886)
mathjazz May 29, 2026
5635a29
1. Skip composed requests for single-field entities. Gate the request
mathjazz Jun 1, 2026
234ed83
Parse composed Machinery suggestions and distribute their values
mathjazz Jun 1, 2026
03f9945
Regular TM matches exclude the entity's own TM entries so a translated
mathjazz Jun 1, 2026
00c5421
Make sure the composed Machinery suggestions are always at the top
mathjazz Jun 17, 2026
8a2e2c4
Sync distributed entry values to live editor
mathjazz Jun 17, 2026
7183e0c
Fix failing test
mathjazz Jun 17, 2026
ab07f2f
Disable AI refinement for composed Machinery suggestions
mathjazz Jun 18, 2026
5b39e3e
Add separator between Machinery source titles
mathjazz Jun 18, 2026
29bbd5e
Rename MT 'provider' to MT 'service' for consistency
mathjazz Jun 30, 2026
4e9c175
Implement proposed walk_entity() changes
mathjazz Jun 30, 2026
1b89483
Replace lambdas with direct function calls
mathjazz Jul 1, 2026
acc79b1
The behaviour should not be gated on the format, but on whether the E…
mathjazz Jul 1, 2026
7b1a079
Avoid direct references to the formats in comments
mathjazz Jul 1, 2026
960112e
Use match statement for handling services and drop COMPOSED_MT_SERVICES
mathjazz Jul 1, 2026
0e049a3
Merge remote-tracking branch 'origin/main' into machinery-compose-mul…
mathjazz Jul 6, 2026
996e443
Add TODO comment to refactor set_accesskey()
mathjazz Jul 6, 2026
26c1a1d
Collapse three MT args into one MTEngine enum
mathjazz Jul 6, 2026
0844741
Move source-string parsing to pontoon.translations
mathjazz Jul 6, 2026
351e8b9
Replace the duplicated local COMPOSED_FORMATS set with the shared spe…
mathjazz Jul 6, 2026
6f2ba0b
In hasMultipleFields(), count leaves straight from entity.value and e…
mathjazz Jul 6, 2026
2966e12
Compose plural targets from single-variant sources
mathjazz Jul 6, 2026
b85b574
More accurate code comment
mathjazz Jul 6, 2026
04ddbd0
Satisfy prettier
mathjazz Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions pontoon/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
Term,
TermTranslation,
)
from pontoon.translations.utils import parse_source_string_to_json

from .serializers import (
TRANSLATION_STATS_FIELDS,
Expand Down Expand Up @@ -523,11 +524,21 @@ def post(self, request):

locale = generics.get_object_or_404(Locale, code=locale)

fmt = resource_format or None
project = SimpleNamespace(slug="temp-project")
resource = SimpleNamespace(project=project, format=resource_format or None)
entity = SimpleNamespace(resource=resource, string=text)
resource = SimpleNamespace(project=project, format=fmt)

try:
# Parsing happens here (not before) so invalid syntax for the chosen
# format is reported as a 400 rather than escaping as a 500.
key, value, properties = parse_source_string_to_json(fmt, text)
entity = SimpleNamespace(
resource=resource,
string=text,
key=key,
value=value,
properties=properties,
)
Comment on lines +534 to +541

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only non-test code that makes any use of the key value returned by parse_source_string_to_json, and the use it's put to is to set the corresponding value in the fake Entity that's constructed here, which is only used to set the id of the temporary Entry that's passed to set_accesskeys, which ignores it.

So we don't actually need to return it in the first case.

pretranslation = get_pretranslation(entity=entity, locale=locale)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs another TODO/FIXME comment about refactoring so that we don't need to construct the fake Project, Resource, and Entity values to call it.

except Exception as e:
return Response(
Expand Down
3 changes: 2 additions & 1 deletion pontoon/base/tests/models/test_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def entity_test_models(translation_a, locale_b):
)
entity_a.string = "Entity zero"
entity_a.key = [entity_a.string]
entity_a.value = [entity_a.string]
entity_a.order = 0
entity_a.save()
entity_b = EntityFactory(
Expand Down Expand Up @@ -294,7 +295,7 @@ def test_entity_project_locale_no_paths(
},
"pk": entity_a.pk,
"original": entity_a.string,
"value": [],
"value": ["Entity zero"],
"date_created": entity_a.date_created,
}
assert e1["path"] == trX.entity.resource.path
Expand Down
319 changes: 319 additions & 0 deletions pontoon/machinery/tests/test_composed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import json

from textwrap import dedent
from unittest.mock import patch

import pytest

from django.urls import reverse

from pontoon.test.factories import (
EntityFactory,
LocaleFactory,
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_single_pattern_message(client, entity_a, locale_a):
"""Single-pattern messages have nothing to compose and skip cleanly.

A DTD entity is a single `PatternMessage` with no properties, so composing it
would just repeat the per-leaf machinery suggestion; 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_single_pattern_fluent(client, fluent_resource, locale_a):
"""A Fluent message with a plain pattern value (no selector, no variants)
and no attributes skips even though its format supports composition."""
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": "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"]
# Every leaf is a 100% TM match, so the composed result is a full TM match.
assert body["quality"] == 100


@pytest.mark.django_db
def test_composed_expands_source_plural_for_target_locale(
client, fluent_resource, entity_a
):
"""A source with a single plural variant composes to multiple target patterns.

en-US declares only `*[other]`, but a locale with several CLDR plural
categories (here one/two/few/other) needs a pattern per category. The walk
expands the selector to the locale's categories, so the entity counts as
multi-pattern even though the source has a single variant, and a composed
suggestion is returned.
"""
locale = LocaleFactory(code="sl-test", name="Plural", cldr_plurals="1,2,3,5")

fluent_string = dedent(
"""\
popup =
{ $count ->
*[other] Many popups.
}
"""
)
fluent_entity = EntityFactory(resource=fluent_resource, string=fluent_string)

TranslationMemoryFactory.create(
entity=entity_a, source="Many popups.", target="TM_popups", locale=locale
)

url = reverse("pontoon.machinery_composed")
response = client.get(
url,
{
"entity": str(fluent_entity.pk),
"locale": locale.code,
"service": "translation-memory",
},
)
assert response.status_code == 200
body = json.loads(response.content)
# The single `*[other]` source expands to all four target plural categories.
translation = body["translation"]
assert "[one]" in translation
assert "[two]" in translation
assert "[few]" in translation
assert "*[other]" in translation
assert translation.count("TM_popups") == 4
assert body["sources"] == ["translation-memory"]
assert body["quality"] == 100
Comment on lines +195 to +202

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be clearer as a single assert body == ... test.



@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) == {}


@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.pretranslation.pretranslate.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"}
# MT-assisted results have no meaningful aggregate quality score.
assert "quality" not in body
Loading
Loading