{"id":99,"date":"2026-09-23T11:24:42","date_gmt":"2026-09-23T11:24:42","guid":{"rendered":"https:\/\/docs.latw.ai\/uncategorized\/checking-translations-before-save-2\/"},"modified":"2026-09-23T11:24:42","modified_gmt":"2026-09-23T11:24:42","slug":"checking-translations-before-save-2","status":"publish","type":"post","link":"https:\/\/docs.latw.ai\/pl\/latw-for-polylang\/checking-translations-before-save-2\/","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 engine 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 not<br \/>\nread the text. If the model mistranslates a term, the mistranslation goes live.<\/p>\n<p>The <code>latwaitp_translated_strings<\/code> filter is the point where your site can look at the finished<br \/>\ntranslation <em>before<\/em> it is written, and either correct it or reject it. It is part of the free<br \/>\nplugin.<\/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 you<br \/>\nmake, but does not make for you.<\/p>\n<h2>Where it runs<\/h2>\n<pre><code>engine answers  \u2192  JSON parsed  \u2192  latwaitp_translated_strings  \u2192  post, term or strings saved<\/code><\/pre>\n<p>It runs for post translations, taxonomy term translations and interface string batches, with every<br \/>\nengine and in every processing mode, in the same background process that saves the translation.<\/p>\n<p>In <strong>delta mode<\/strong> (&#8220;Translate only changed strings&#8221;, PRO) 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 translated post<br \/>\nitself rather than assume <code>$strings<\/code> is complete. An update that turned out to need no translation<br \/>\nat all does not call the filter.<\/p>\n<h2>The filter<\/h2>\n<pre><code class=\"language-php\">add_filter( 'latwaitp_translated_strings', function ( $strings, $translation ) {\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 the plugin&#8217;s own:<br \/>\n<code>post_title<\/code>, <code>post_excerpt<\/code>, <code>content_1<\/code>, <code>meta_1<\/code>, <code>builder_1<\/code> for posts (<code>pll_1<\/code> and so on on<br \/>\nPolylang Pro), <code>name<\/code> and <code>description<\/code> for terms, <code>string_\u2026<\/code> for interface strings.<\/p>\n<p><code>$translation<\/code> is the job&#8217;s row in the plugin&#8217;s queue:<\/p>\n<table>\n<thead>\n<tr>\n<th>Key<\/th>\n<th>Meaning<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>id<\/code><\/td>\n<td>Row id, as shown on the <strong>History<\/strong> screen.<\/td>\n<\/tr>\n<tr>\n<td><code>element_type<\/code><\/td>\n<td><code>pll_post_job<\/code> for posts, <code>term<\/code> for taxonomy terms, <code>pll_string_job<\/code> for interface strings.<\/td>\n<\/tr>\n<tr>\n<td><code>source_post_id<\/code><\/td>\n<td>The original post, or the original term for <code>term<\/code>. <code>0<\/code> for interface strings.<\/td>\n<\/tr>\n<tr>\n<td><code>source_lang<\/code><\/td>\n<td>Language slug translated from, e.g. <code>en<\/code>.<\/td>\n<\/tr>\n<tr>\n<td><code>target_lang<\/code><\/td>\n<td>Language slug translated into, e.g. <code>de<\/code>.<\/td>\n<\/tr>\n<tr>\n<td><code>model<\/code>, <code>provider<\/code><\/td>\n<td>The model and engine this job was actually sent to.<\/td>\n<\/tr>\n<tr>\n<td><code>translation_type<\/code><\/td>\n<td><code>on-demand<\/code> or <code>auto<\/code>, with <code>-delta<\/code> or <code>-delta-context<\/code> for an update that sent only what changed.<\/td>\n<\/tr>\n<tr>\n<td><code>source_strings_json<\/code><\/td>\n<td>JSON of the fields that make up the job, each with the <strong>source<\/strong> text in <code>data<\/code>. Not set for terms.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The source text of a field:<\/p>\n<pre><code class=\"language-php\">$source = json_decode( (string) $translation['source_strings_json'], true );\n$text   = isset( $source[ $field ]['data'] ) ? $source[ $field ]['data'] : '';<\/code><\/pre>\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( 'latwaitp_translated_strings', function ( $strings, $translation ) {\n    if ( $translation['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><strong>Do not remove a field to skip it.<\/strong> The translated post is rebuilt from the original, so a title,<br \/>\nexcerpt, paragraph or Elementor text missing from the array is written back in the <strong>source<br \/>\nlanguage<\/strong>. Fields you add are ignored \u2014 there is no place in the post to put a field that was never<br \/>\npart of the job.<\/p>\n<h3>Rejecting a translation<\/h3>\n<p>Return a <code>WP_Error<\/code> \u2014 or an empty array \u2014 and nothing is saved. The job is marked <strong>failed<\/strong>, the<br \/>\npost keeps whatever translation it had before, and the reason is stored on the job and written to<br \/>\nthe log.<\/p>\n<p>Anything other than an array is treated as a rejection too, so always return <code>$strings<\/code> from a<br \/>\nbranch that does not apply.<\/p>\n<pre><code class=\"language-php\">\/\/ Words that must never appear, per language.\nadd_filter( 'latwaitp_translated_strings', function ( $strings, $translation ) {\n    $banned = array(\n        'de' =&gt; array( '\/bBodensatzb\/u' ),\n        'it' =&gt; array( '\/bspremiagrumib\/iu' ),\n    );\n\n    if ( empty( $banned[ $translation['target_lang'] ] ) ) {\n        return $strings;\n    }\n\n    foreach ( $strings as $field =&gt; $translated ) {\n        foreach ( $banned[ $translation['target_lang'] ] as $pattern ) {\n            if ( preg_match( $pattern, $translated ) ) {\n                return new WP_Error(\n                    'mysite_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( 'latwaitp_translated_strings', function ( $strings, $translation ) {\n    $source      = json_decode( (string) $translation['source_strings_json'], true );\n    $measurement = '\/d+(?:[.,]d+)?s?(?:mm|cm|m|ml|l|g|kg|%)b\/u';\n\n    foreach ( $strings as $field =&gt; $translated ) {\n        $text = isset( $source[ $field ]['data'] ) ? $source[ $field ]['data'] : '';\n\n        preg_match_all( $measurement, wp_strip_all_tags( $text ), $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                'mysite_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 rejected job is not retried.<\/strong> It stays failed until someone translates the post again, and<br \/>\nthat sends the same prompt again: nothing tells the model what you objected to, so a model that made<br \/>\na deliberate word choice will usually make it again \u2014 at your provider&#8217;s expense. Rejection is best<br \/>\nused for mistakes a model makes <em>occasionally<\/em> (a dropped number, a truncated field). For a word the<br \/>\nmodel keeps choosing, correct it in the filter, add the preferred term to the glossary, or say so in<br \/>\nthe translation prompt.<\/p>\n<h2>Where to check on it<\/h2>\n<ul>\n<li><strong>AI Translation \u2192 History<\/strong> shows the job as <code>failed<\/code>, with your message in the <strong>Error<\/strong> column.<\/li>\n<li>The plugin log (<strong>AI Translation \u2192 Logs<\/strong>) records <code>Response processing failed<\/code> with the error<br \/>\nmessage.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>What it does By default a translation is saved as soon as the engine 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":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_latwm_translated_slug":"","footnotes":""},"categories":[17],"tags":[],"class_list":["post-99","post","type-post","status-publish","format-standard","hentry","category-latw-for-polylang"],"_links":{"self":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/99","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=99"}],"version-history":[{"count":0,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/99\/revisions"}],"wp:attachment":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/media?parent=99"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/categories?post=99"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/tags?post=99"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}