Skip to content

Commit 37d2ffb

Browse files
committed
Merge branch 'flow-builder' into develop
2 parents 3e7459a + 610a021 commit 37d2ffb

473 files changed

Lines changed: 14995 additions & 9963 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

scripts/translate.mjs

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,11 @@ async function translateFileWithSections(
12331233
extractCodeBlocks(section.content),
12341234
);
12351235
patchCount++;
1236+
} else if (!hasTranslatableProse(section.content)) {
1237+
// No natural-language text (code-only or empty) — keep source verbatim, no
1238+
// API call. Sending these elicits refusal replies that corrupt the file.
1239+
console.log(` · ${section.id} → skip (no translatable text)`);
1240+
translation = section.content;
12361241
} else {
12371242
// Prose changed (or first translation) — call Claude
12381243
const reason = !cached
@@ -1273,6 +1278,29 @@ async function translateFileWithSections(
12731278
);
12741279
}
12751280
translation = response.content[0].text;
1281+
if (looksLikeRefusal(translation)) {
1282+
console.warn(
1283+
` ⚠ ${section.id} → reply looked like a refusal; retrying with hardened prompt`,
1284+
);
1285+
let retried = "";
1286+
try {
1287+
retried = await translateFragmentHardened(
1288+
client,
1289+
systemPrompt,
1290+
section.content,
1291+
);
1292+
} catch (e) {
1293+
console.error(` retry failed: ${e.message}`);
1294+
}
1295+
if (!retried.trim() || looksLikeRefusal(retried)) {
1296+
console.error(
1297+
` ✗ ${section.id} → still refused; keeping English source for this section`,
1298+
);
1299+
translation = section.content;
1300+
} else {
1301+
translation = retried;
1302+
}
1303+
}
12761304
apiCallCount++;
12771305
}
12781306

@@ -1448,6 +1476,16 @@ async function buildSectionPlan(file, lang, localesDir, hashesDir) {
14481476
proseHash,
14491477
cachedTranslation: patched,
14501478
});
1479+
} else if (!hasTranslatableProse(section.content)) {
1480+
// No natural-language text (code-only or empty) — pass through unchanged,
1481+
// no batch entry. Sending these elicits refusal replies that corrupt files.
1482+
decisions.push({
1483+
section,
1484+
kind: "skip",
1485+
contentHash,
1486+
proseHash,
1487+
cachedTranslation: section.content,
1488+
});
14511489
} else {
14521490
decisions.push({ section, kind: "send", contentHash, proseHash });
14531491
}
@@ -1497,6 +1535,7 @@ async function translateBatchSections(
14971535
customIdLookup[customId] = {
14981536
basename: plan.basename,
14991537
sectionId: decision.section.id,
1538+
content: decision.section.content,
15001539
};
15011540
allEntries.push({
15021541
custom_id: customId,
@@ -1515,10 +1554,10 @@ async function translateBatchSections(
15151554
for (const d of p.decisions) acc[d.kind]++;
15161555
return acc;
15171556
},
1518-
{ hit: 0, patch: 0, send: 0 },
1557+
{ hit: 0, patch: 0, send: 0, skip: 0 },
15191558
);
15201559
console.log(
1521-
`${tag} Sections: ${stats.hit} cached, ${stats.patch} code-patch, ${stats.send} to translate.`,
1560+
`${tag} Sections: ${stats.hit} cached, ${stats.patch} code-patch, ${stats.skip} skipped (no text), ${stats.send} to translate.`,
15221561
);
15231562

15241563
if (flagDryRun) {
@@ -1579,7 +1618,33 @@ async function translateBatchSections(
15791618
hadErrors = true;
15801619
continue;
15811620
}
1582-
translationsByCustomId[r.custom_id] = r.result.message.content[0].text;
1621+
const text = r.result.message.content[0].text;
1622+
if (looksLikeRefusal(text)) {
1623+
const meta = customIdLookup[r.custom_id];
1624+
console.warn(
1625+
` ⚠ ${meta?.basename}::${meta?.sectionId} (${r.custom_id}): reply looked like a refusal — retrying with hardened prompt`,
1626+
);
1627+
let retried = "";
1628+
try {
1629+
retried = await translateFragmentHardened(
1630+
client,
1631+
systemPrompt,
1632+
meta?.content ?? "",
1633+
);
1634+
} catch (e) {
1635+
console.error(` retry failed: ${e.message}`);
1636+
}
1637+
if (!retried.trim() || looksLikeRefusal(retried)) {
1638+
console.error(
1639+
` ✗ ${meta?.basename}::${meta?.sectionId}: still refused — keeping English source for this section`,
1640+
);
1641+
translationsByCustomId[r.custom_id] = meta?.content ?? "";
1642+
} else {
1643+
translationsByCustomId[r.custom_id] = retried;
1644+
}
1645+
continue;
1646+
}
1647+
translationsByCustomId[r.custom_id] = text;
15831648
} else {
15841649
console.error(
15851650
` ✗ ${customIdLookup[r.custom_id]?.basename}::${customIdLookup[r.custom_id]?.sectionId} (${r.custom_id}): ${JSON.stringify(r.result)}`,
@@ -1599,7 +1664,11 @@ async function translateBatchSections(
15991664

16001665
for (const decision of plan.decisions) {
16011666
let translation;
1602-
if (decision.kind === "hit" || decision.kind === "patch") {
1667+
if (
1668+
decision.kind === "hit" ||
1669+
decision.kind === "patch" ||
1670+
decision.kind === "skip"
1671+
) {
16031672
translation = decision.cachedTranslation;
16041673
} else {
16051674
const customId = customIdFor(plan.basename, decision.section.id);
@@ -3259,6 +3328,68 @@ function stripCodeBlocks(content) {
32593328
return result.join("\n");
32603329
}
32613330

3331+
/**
3332+
* True when `content` has natural-language text worth sending to the translator.
3333+
* After stripping fenced code blocks, a section with no Unicode letter is empty
3334+
* or code-only. Sending such fragments elicits model refusals ("you've shared a
3335+
* code snippet…", "the input appears to be empty…") that were being written
3336+
* verbatim into locale files. These sections are passed through unchanged
3337+
* instead of translated — code and empty blocks need no translation anyway.
3338+
*/
3339+
function hasTranslatableProse(content) {
3340+
return /\p{L}/u.test(stripCodeBlocks(content));
3341+
}
3342+
3343+
/**
3344+
* Detects when the model replied with an apology / request-for-input instead of
3345+
* a translation. Such replies appear when a fragment looks empty or code-only to
3346+
* the model. Matched against the start of the reply (refusals always lead); the
3347+
* scoped phrases keep false positives away from legitimate translated bodies.
3348+
*/
3349+
const REFUSAL_PATTERNS = [
3350+
/\bappears to be (empty|incomplete|just a|only a)\b/i,
3351+
/\b(please|could you)\s+(please\s+)?(paste|provide|share)\b[^.\n]{0,60}\b(mdx|document|documentation|content|text)\b/i,
3352+
/\byou'?d like (me )?(to )?translate/i,
3353+
/\bI'?ll get it done\b/i,
3354+
/\bit looks like (you|the (content|input|document|message|text))/i,
3355+
/\bI notice (the|that)\b[^.\n]{0,60}\b(empty|incomplete|missing)\b/i,
3356+
/\bshared a code snippet\b/i,
3357+
/\bno actual content to translate\b/i,
3358+
/\bcontent (you want translated|to translate) is (empty|incomplete|missing)\b/i,
3359+
];
3360+
function looksLikeRefusal(text) {
3361+
if (!text) return false;
3362+
const head = text.slice(0, 400);
3363+
return REFUSAL_PATTERNS.some((re) => re.test(head));
3364+
}
3365+
3366+
/**
3367+
* Re-translate a short/odd fragment with an explicit, refusal-proof instruction.
3368+
* Used as a fallback when the normal translation reply looked like a refusal.
3369+
* The returned text may itself still be a refusal — the caller must re-check.
3370+
*/
3371+
async function translateFragmentHardened(client, systemPrompt, content) {
3372+
const resp = await withRetry(() =>
3373+
client.messages.create({
3374+
model: "claude-sonnet-4-6",
3375+
max_tokens: 8192,
3376+
system: cachedSystem(systemPrompt),
3377+
messages: [
3378+
{
3379+
role: "user",
3380+
content:
3381+
"Translate the natural-language text in the MDX fragment below. " +
3382+
"It may be very short — a single heading, label, table, or code block — but it is NOT empty. " +
3383+
"Translate all prose; leave code, markup, URLs, attributes, and identifiers unchanged. " +
3384+
"Return ONLY the translated fragment, with no commentary, apology, or request for more input:\n\n" +
3385+
content,
3386+
},
3387+
],
3388+
}),
3389+
);
3390+
return resp.content?.[0]?.text ?? "";
3391+
}
3392+
32623393
/**
32633394
* Extract all fenced code blocks from content, in traversal order.
32643395
* Each entry is the full block string from opening fence to closing fence.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"fileHash":"sha256:53d6d5ee0e90623c2fc37de703a4459e1da30c8fd1092077fb2f0fab046c02f4"}
1+
{"fileHash":"sha256:9337242a605613926644e5d1138edfb17d89d705ac5e9039578dba62fb7e3110","sections":{"preamble":{"contentHash":"sha256:bb48bd3f5f4015f0b40f9693b8bd9c061e5e204c789a29986a2daaf6b00ac9ba","proseHash":"sha256:bb48bd3f5f4015f0b40f9693b8bd9c061e5e204c789a29986a2daaf6b00ac9ba","translation":"---\ntitle: \"Añadir variantes de prueba A/B sin paywalls\"\ndescription: \"Ejecuta una prueba A/B donde una variante omite el paywall, usando una bandera de Remote Config para controlar si el paywall se muestra.\"\nmetadataTitle: \"Variantes de Prueba A/B Sin Paywalls | Documentación de Adapty\"\n---\n\nPuedes medir el impacto de tu paywall ejecutando una prueba A/B contra una variante vacía. Una variante muestra tu paywall; la otra no muestra nada. Tu app lee una bandera del Remote Config del paywall para decidir si renderizarlo.\n"},"h2-how-it-works":{"contentHash":"sha256:ad697c7e1b2b1f389c2305fe1ed7a69de4abcf9eedd1ab3ea220744620e4f3d8","proseHash":"sha256:ad697c7e1b2b1f389c2305fe1ed7a69de4abcf9eedd1ab3ea220744620e4f3d8","translation":"## Cómo funciona \\{#how-it-works\\}\n\nLa configuración usa dos paywalls en el mismo placement:\n\n- **Paywall A**: El paywall que quieres probar, con `show_paywall` establecido en `true` en su Remote Config.\n- **Paywall B**: Un paywall vacío con `show_paywall` establecido en `false` en su Remote Config.\n\nCuando `getPaywall` devuelve un paywall, tu app lee la bandera `show_paywall`. Si la bandera es `true`, la app renderiza el paywall. Si la bandera es `false`, la app omite el renderizado y el usuario continúa sin ver un paywall.\n"},"h2-1-add-the-showpaywall-flag-in-remote-config-pac8c4e1a":{"contentHash":"sha256:ac8c4e1ac2b00e24bdf4730a96bd2e36b1fd1acd3cba8cffe730bbf4997c78e5","proseHash":"sha256:ac8c4e1ac2b00e24bdf4730a96bd2e36b1fd1acd3cba8cffe730bbf4997c78e5","translation":"## 1. Añade el flag show_paywall en el Remote Config \\{#1-add-the-show_paywall-flag-in-remote-config\\}\n\nNecesitas dos flows o paywalls en el mismo placement: Flow/Paywall A (el que quieres probar) y Flow/Paywall B (uno vacío). Añade un campo `show_paywall` a cada uno para que tu app pueda ramificar en la misma clave para ambas variantes.\n\nPara añadir el flag al Flow/Paywall A:"},"h2-1-add-the-showpaywall-flag-in-remote-config-p6d35b38c":{"contentHash":"sha256:6d35b38c657d9794497494e671b7bbd28238b7815eb323771b4227304d9d5adc","proseHash":"sha256:6d35b38c657d9794497494e671b7bbd28238b7815eb323771b4227304d9d5adc","translation":"1. Abre la sección [**Flows**](https://app.adapty.io/flows)/[**Paywalls**](https://app.adapty.io/paywalls) en el menú principal de Adapty y selecciona el Flow/Paywall A.\n2. Abre la sección **Remote config**.\n3. Crea un campo con el nombre `show_paywall` y el valor `true`. En la vista **JSON**, la entrada queda así:\n\n ```json showLineNumbers\n {\n \"show_paywall\": true\n }\n ```\n\n4. Guarda los cambios.\n\nRepite los mismos pasos para el Flow/Paywall B, pero establece `show_paywall` en `false`."},"h2-1-add-the-showpaywall-flag-in-remote-config-p94b8397c":{"contentHash":"sha256:94b8397cac4a3a1856b5da737059636146861ed6f2bf2478e2eebbf8d750d8be","proseHash":"sha256:94b8397cac4a3a1856b5da737059636146861ed6f2bf2478e2eebbf8d750d8be","translation":"Para obtener todos los detalles sobre Remote Config, consulta [Personalizar el flow con Remote Config](customize-flow-with-remote-config) o [Diseñar el paywall con Remote Config](customize-paywall-with-remote-config).\n\n:::tip\nEstablecer `show_paywall` en ambas variantes mantiene la ruta de código idéntica para ambos grupos y facilita extender la prueba con más variantes más adelante.\n:::"},"h2-2-set-up-the-ab-test":{"contentHash":"sha256:aefb5388a04d7e87f17513577915a7d6ddea2249a297d8a4abfdd88f342ca8df","proseHash":"sha256:aefb5388a04d7e87f17513577915a7d6ddea2249a297d8a4abfdd88f342ca8df","translation":"## 2. Configura la prueba A/B \\{#2-set-up-the-ab-test\\}\n\n1. [Crea una prueba A/B](run_stop_ab_tests) en el placement y añade ambos paywalls como variantes.\n2. Establece los pesos de las variantes para distribuir el tráfico entre los usuarios que ven el paywall y los que no.\n"},"h2-3-check-the-flag-in-your-app-p7d166a34":{"contentHash":"sha256:7d166a34112be2ea2431ad83bdccc9869a9f251a206874c099c4053dde1c55cc","proseHash":"sha256:7d166a34112be2ea2431ad83bdccc9869a9f251a206874c099c4053dde1c55cc","translation":"## 3. Comprueba la flag en tu app \\{#3-check-the-flag-in-your-app\\}\n\nLee `show_paywall` desde el Remote Config devuelto por el SDK. Si la flag es `false`, omite el renderizado y deja que el usuario continúe.\n\n<Tabs groupId=\"current-os\" queryString>\n<TabItem value=\"swift\" label=\"iOS\" default>"},"h2-3-check-the-flag-in-your-app-p2ea1ded1":{"contentHash":"sha256:0478dbe7d6382264482b8ac1d55fc3fb6c847aba02f4b7b17889694260450b0c","proseHash":"sha256:2ea1ded1b263b9398a149fc57b9ed85dfabafee2230d87f8d287f135268428ad","translation":"```swift showLineNumbers\ndo {\n let flow = try await Adapty.getFlow(placementId: \"YOUR_PLACEMENT_ID\")\n let config = flow.remoteConfigs.first(where: { $0.locale == \"en\" })\n ?? flow.remoteConfigs.first\n let showPaywall = config?.dictionary?[\"show_paywall\"] as? Bool ?? true\n\n if showPaywall {\n // render the flow or paywall\n }\n} catch {\n // handle the error\n}\n```\n\n</TabItem>\n\n<TabItem value=\"kotlin\" label=\"Android\">"},"h2-3-check-the-flag-in-your-app-pdf5be7d3":{"contentHash":"sha256:a3fecbe3ee10750db1e7691123a9dcbecbed46ce15af8fe42f2ef518789e5a30","proseHash":"sha256:df5be7d344cb3e8597354b1d02dabb09b956cb318c61708c9a7c4eab76b07120","translation":"```kotlin showLineNumbers\nAdapty.getPaywall(\"YOUR_PLACEMENT_ID\") { result ->\n when (result) {\n is AdaptyResult.Success -> {\n val paywall = result.value\n val showPaywall = paywall.remoteConfig?.dataMap?.get(\"show_paywall\") as? Boolean ?: true\n\n if (showPaywall) {\n // Render the paywall\n }\n }\n is AdaptyResult.Error -> {\n // handle the error\n }\n }\n}\n```\n\n</TabItem>\n\n<TabItem value=\"react-native\" label=\"React Native\">"},"h2-3-check-the-flag-in-your-app-p36bf4654":{"contentHash":"sha256:00ecc0bb6d89f28d585ca38ae262a49ac3c9ec9d31b544afe395de21e13cd76e","proseHash":"sha256:36bf46547c820310aa3b085cb25cfea38caf771fde3db18a44e168bf13f41b78","translation":"```typescript showLineNumbers\ntry {\n const paywall = await adapty.getPaywall({ placementId: \"YOUR_PLACEMENT_ID\" });\n const showPaywall = paywall.remoteConfig?.data?.[\"show_paywall\"] ?? true;\n\n if (showPaywall) {\n // Render the paywall\n }\n} catch (error) {\n // handle the error\n}\n```\n\n</TabItem>\n\n<TabItem value=\"flutter\" label=\"Flutter\">"},"h2-3-check-the-flag-in-your-app-pbbfdb1b1":{"contentHash":"sha256:9364c9237fcf14dc2223f25dd522a33495cb22042eeace70a568121d74f959ca","proseHash":"sha256:bbfdb1b1c784bbacd28bb679911b51fa86f80005f133c16d1bdf54739e16b355","translation":"```dart showLineNumbers\ntry {\n final paywall = await Adapty().getPaywall(id: \"YOUR_PLACEMENT_ID\");\n final bool showPaywall = paywall.remoteConfig?.dictionary?['show_paywall'] as bool? ?? true;\n\n if (showPaywall) {\n // Render the paywall\n }\n} on AdaptyError catch (adaptyError) {\n // handle the error\n}\n```\n\n</TabItem>\n\n<TabItem value=\"unity\" label=\"Unity\">"},"h2-3-check-the-flag-in-your-app-p51d48a92":{"contentHash":"sha256:193f81913cd722184b550d6e30639ef9f19955db6b5be5817c7ccfc32a2ec096","proseHash":"sha256:51d48a92c5a292a24bf189cbacece2d417299da98a996080eff611edcdde56eb","translation":"```csharp showLineNumbers\nAdapty.GetPaywall(\"YOUR_PLACEMENT_ID\", (paywall, error) => {\n if (error != null) {\n // handle the error\n return;\n }\n\n var showPaywall = paywall.RemoteConfig?.Dictionary?[\"show_paywall\"] as bool? ?? true;\n\n if (showPaywall) {\n // Render the paywall\n }\n});\n```\n\n</TabItem>\n\n<TabItem value=\"kmp\" label=\"Kotlin Multiplatform\">"},"h2-3-check-the-flag-in-your-app-pb3aa9c83":{"contentHash":"sha256:222a97edc4b2dac39f273ddbbecb4cd36ed5c507ac0d07c88387738052f1f622","proseHash":"sha256:b3aa9c832037a10445b441497879bfc3a88cbd73694d1800d8e9cb6d918fdb4d","translation":"```kotlin showLineNumbers\nAdapty.getPaywall(\n placementId = \"YOUR_PLACEMENT_ID\"\n).onSuccess { paywall ->\n val showPaywall = paywall.remoteConfig?.dataMap?.get(\"show_paywall\") as? Boolean ?: true\n\n if (showPaywall) {\n // Render the paywall\n }\n}.onError { error ->\n // handle the error\n}\n```\n\n</TabItem>\n\n<TabItem value=\"capacitor\" label=\"Capacitor\">"},"h2-3-check-the-flag-in-your-app-pf45b5d45":{"contentHash":"sha256:ed9adc978fa972c172209e60c85d2b6fa570691f073ba51f93677023fcc5f1dd","proseHash":"sha256:f45b5d459003f5101850e4903e8460ecb56ea214070e802e8eb35df6e78fdbf1","translation":"```typescript showLineNumbers\nimport { adapty } from '@adapty/capacitor';\n\ntry {\n const paywall = await adapty.getPaywall({ placementId: 'YOUR_PLACEMENT_ID' });\n const showPaywall = paywall.remoteConfig?.data?.['show_paywall'] ?? true;\n\n if (showPaywall) {\n // Render the paywall\n }\n} catch (error) {\n // handle the error\n}\n```\n\n</TabItem>\n</Tabs>\n\nEl valor de respaldo `true` mantiene el flow/paywall visible cuando la bandera no existe, por lo que los flows/paywalls existentes que no incluyan la bandera no se ven afectados."},"h2-3-check-the-flag-in-your-app-p5272be6b":{"contentHash":"sha256:5272be6bbd287c130ebf1db5c1cbea5af6946f12c9d4668df02bcb1a37573f07","proseHash":"sha256:5272be6bbd287c130ebf1db5c1cbea5af6946f12c9d4668df02bcb1a37573f07","translation":":::important\nSi renderizas el paywall tú mismo (sin el [Flow Builder](adapty-flow-builder) ni el [Paywall Builder](adapty-paywall-builder)), llama a [`logShowFlow` (iOS SDK v4+) / `logShowPaywall`](present-remote-config-paywalls#track-paywall-view-events) cuando muestres el Flow/Paywall A. Sin esto, Adapty no puede contabilizar las visualizaciones en la prueba. No registres una visualización para el Flow/Paywall B, ya que nunca se muestra.\n:::"},"h2-next-steps":{"contentHash":"sha256:6535c8aff23e17cf18cd07852b3132f56acfd70253369c5fedb006827206a6a9","proseHash":"sha256:6535c8aff23e17cf18cd07852b3132f56acfd70253369c5fedb006827206a6a9","translation":"## Próximos pasos \\{#next-steps\\}\n\n- [Crear, ejecutar y detener una prueba A/B](run_stop_ab_tests) — Configura el test que incluye ambas variantes\n- [Resultados y métricas de la prueba A/B](results-and-metrics) — Compara la variante sin paywall con tu paywall"}}}

0 commit comments

Comments
 (0)