{"id":62,"date":"2026-09-16T10:29:02","date_gmt":"2026-09-16T10:29:02","guid":{"rendered":"https:\/\/docs.latw.ai\/uncategorized\/checking-translations-before-save\/"},"modified":"2026-09-16T10:29:02","modified_gmt":"2026-09-16T10:29:02","slug":"checking-translations-before-save","status":"publish","type":"post","link":"https:\/\/docs.latw.ai\/pl\/latw-for-wpml\/checking-translations-before-save\/","title":{"rendered":"Checking Translations Before They Are Saved"},"content":{"rendered":"<h2>What it does<\/h2>\n<p>By default a translation is saved as soon as the AI returns a well-formed answer. The plugin<br \/>\nchecks that the answer is valid JSON and that its fields match the ones that were sent \u2014 it does<br \/>\nnot read the text. If the model mistranslates a term, the mistranslation goes live.<\/p>\n<p>The <code>latwaitr_translated_strings<\/code> filter is the point where your site can look at the finished<br \/>\ntranslation <em>before<\/em> WPML writes it, and either correct it or reject it.<\/p>\n<p>Typical uses:<\/p>\n<ul>\n<li>terminology your glossary cannot express \u2014 the glossary says &#8220;use X&#8221;, not &#8220;never use Y&#8221;;<\/li>\n<li>integrity checks, e.g. every measurement in the source must survive into the translation;<\/li>\n<li>a search-and-replace your editors keep applying by hand after every translation.<\/li>\n<\/ul>\n<p><strong>What it cannot do.<\/strong> These are text checks, not comprehension. A translation that reverses the<br \/>\nmeaning of a sentence while keeping every number, tag and glossary term intact will pass every<br \/>\ndeterministic rule you write here. Catching that needs a second AI call, which this filter lets<br \/>\nyou make, but does not make for you.<\/p>\n<h2>Where it runs<\/h2>\n<pre><code>AI answers  \u2192  JSON parsed  \u2192  fields matched to WPML tids  \u2192  latwaitr_translated_strings  \u2192  WPML saves<\/code><\/pre>\n<p>It runs for post translations and String Translation batches, in both background and synchronous<br \/>\nmode, and on the same cron process that saves the translation. Taxonomy term translations do not<br \/>\ngo through it.<\/p>\n<p>In <strong>delta mode<\/strong> (&#8220;Translate only changed strings&#8221;) the filter only ever sees the fields that<br \/>\nwere actually sent to the AI. The unchanged parts of the post keep their existing translation and<br \/>\nnever reach your code \u2014 so a check that needs to see the whole post should load the target post<br \/>\nitself rather than assume <code>$strings<\/code> is complete.<\/p>\n<h2>The filter<\/h2>\n<pre><code class=\"language-php\">add_filter( 'latwaitr_translated_strings', function ( $strings, $context ) {\n    return $strings;\n}, 10, 2 );<\/code><\/pre>\n<p><code>$strings<\/code> is an array of <code>field name =&gt; translated text<\/code>. The field names are WPML&#8217;s<br \/>\n(<code>title<\/code>, <code>body<\/code>, <code>field-&lt;something&gt;<\/code> for page builders, and so on).<\/p>\n<p><code>$context<\/code> describes the job:<\/p>\n<table>\n<thead>\n<tr>\n<th>Key<\/th>\n<th>Type<\/th>\n<th>Meaning<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>source<\/code><\/td>\n<td>array<\/td>\n<td>The same field names, holding the <strong>source<\/strong> text that was sent.<\/td>\n<\/tr>\n<tr>\n<td><code>source_lang<\/code><\/td>\n<td>string<\/td>\n<td>Language code translated from, e.g. <code>en<\/code>.<\/td>\n<\/tr>\n<tr>\n<td><code>target_lang<\/code><\/td>\n<td>string<\/td>\n<td>Language code translated into, e.g. <code>de<\/code>.<\/td>\n<\/tr>\n<tr>\n<td><code>source_post_id<\/code><\/td>\n<td>int<\/td>\n<td>The original post. <code>0<\/code> for String Translation batches.<\/td>\n<\/tr>\n<tr>\n<td><code>element_type<\/code><\/td>\n<td>string<\/td>\n<td><code>ts_job<\/code> for posts, <code>ts_string_job<\/code> for String Translation batches.<\/td>\n<\/tr>\n<tr>\n<td><code>model<\/code><\/td>\n<td>string<\/td>\n<td>The model this job was actually sent to.<\/td>\n<\/tr>\n<tr>\n<td><code>wpml_job_id<\/code><\/td>\n<td>int<\/td>\n<td>WPML translation job id.<\/td>\n<\/tr>\n<tr>\n<td><code>translation_id<\/code><\/td>\n<td>int<\/td>\n<td>Row id in the plugin&#8217;s queue table, as shown on the Translations screen.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h3>Returning a corrected translation<\/h3>\n<p>Return the array with whatever changes you want saved.<\/p>\n<pre><code class=\"language-php\">\/\/ Editors kept fixing this one word by hand after every translation.\nadd_filter( 'latwaitr_translated_strings', function ( $strings, $context ) {\n    if ( $context['target_lang'] !== 'de' ) {\n        return $strings;\n    }\n\n    foreach ( $strings as $field =&gt; $translated ) {\n        $strings[ $field ] = str_replace( 'Bodensatz', 'Sediment', $translated );\n    }\n\n    return $strings;\n}, 10, 2 );<\/code><\/pre>\n<p>Fields you remove from the array are not written: the field keeps the translation it already had,<br \/>\nrather than being emptied. Fields you add are ignored \u2014 WPML has no place to put a field that was<br \/>\nnever part of the job. A return value that is not an array is ignored, and the model&#8217;s translation<br \/>\nis saved unchanged.<\/p>\n<h3>Rejecting a translation<\/h3>\n<p>Return a <code>WP_Error<\/code> and nothing is saved. The job is then treated exactly like a failed API call:<br \/>\nit is sent to the model again, up to three attempts in total, and parked as <strong>failed<\/strong> after that.<br \/>\nThe post keeps whatever translation it had before, and the reason is stored on the queue row and<br \/>\nwritten to the log.<\/p>\n<pre><code class=\"language-php\">\/\/ Words that must never appear, per language.\nadd_filter( 'latwaitr_translated_strings', function ( $strings, $context ) {\n    $banned = array(\n        'de' =&gt; array( '\/bBodensatzb\/u' ),\n        'it' =&gt; array( '\/bspremiagrumib\/iu' ),\n        'hr' =&gt; array( '\/bpresudab\/iu' ),\n    );\n\n    if ( empty( $banned[ $context['target_lang'] ] ) ) {\n        return $strings;\n    }\n\n    foreach ( $strings as $field =&gt; $translated ) {\n        foreach ( $banned[ $context['target_lang'] ] as $pattern ) {\n            if ( preg_match( $pattern, $translated ) ) {\n                return new WP_Error(\n                    'latwaitrp_banned_term',\n                    sprintf( 'Banned term in field \"%s\" (%s)', $field, $pattern )\n                );\n            }\n        }\n    }\n\n    return $strings;\n}, 10, 2 );<\/code><\/pre>\n<pre><code class=\"language-php\">\/\/ Every measurement in the source must appear in the translation.\nadd_filter( 'latwaitr_translated_strings', function ( $strings, $context ) {\n    $measurement = '\/d+(?:[.,]d+)?s?(?:mm|cm|m|ml|l|g|kg|%)b\/u';\n\n    foreach ( $strings as $field =&gt; $translated ) {\n        $source = isset( $context['source'][ $field ] ) ? $context['source'][ $field ] : '';\n\n        preg_match_all( $measurement, wp_strip_all_tags( $source ), $in_source );\n        preg_match_all( $measurement, wp_strip_all_tags( $translated ), $in_translation );\n\n        if ( count( $in_source[0] ) !== count( $in_translation[0] ) ) {\n            return new WP_Error(\n                'latwaitrp_measurements_lost',\n                sprintf(\n                    'Field \"%s\": %d measurements in the source, %d in the translation.',\n                    $field,\n                    count( $in_source[0] ),\n                    count( $in_translation[0] )\n                )\n            );\n        }\n    }\n\n    return $strings;\n}, 10, 2 );<\/code><\/pre>\n<p><strong>A retry sends the same prompt again.<\/strong> Nothing tells the model what you objected to, so a model<br \/>\nthat made a deliberate word choice will usually make it again and the job will end up failed after<br \/>\nthree attempts \u2014 at your provider&#8217;s expense. Rejection is best used for mistakes a model makes<br \/>\n<em>occasionally<\/em> (a dropped number, a truncated field). For a word the model keeps choosing, correct<br \/>\nit in the filter, add the preferred term to the glossary, or say so in the translation prompt.<\/p>\n<h2>Where to check on it<\/h2>\n<ul>\n<li><strong>AI Translator \u2192 Translations<\/strong> shows the row&#8217;s status and the stored reason: <code>Retrying (attempt 1): Rejected: \u2026<\/code>, or <code>Max retries exceeded. Last error: Rejected: \u2026<\/code>.<\/li>\n<li>The plugin log records <code>Translation rejected by latwaitr_translated_strings<\/code> with the field and<br \/>\nthe error message, and <code>Retrying failed response<\/code> for each new attempt.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>What it does By default a translation is saved as soon as the AI returns a well-formed answer. The plugin checks that the answer is valid\u2026<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_latwm_translated_slug":"","footnotes":""},"categories":[3],"tags":[],"class_list":["post-62","post","type-post","status-publish","format-standard","hentry","category-latw-for-wpml"],"_links":{"self":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/62","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/comments?post=62"}],"version-history":[{"count":2,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/62\/revisions"}],"predecessor-version":[{"id":78,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/62\/revisions\/78"}],"wp:attachment":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/media?parent=62"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/categories?post=62"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/tags?post=62"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}