Skip to content

fix(localize): restore sheet writes, disambiguate locale codes, split failing batches - #10

Merged
mike-doctorina merged 8 commits into
masterfrom
fix/localization-robustness
Jul 14, 2026
Merged

fix(localize): restore sheet writes, disambiguate locale codes, split failing batches#10
mike-doctorina merged 8 commits into
masterfrom
fix/localization-robustness

Conversation

@mike-doctorina

Copy link
Copy Markdown
Collaborator

Критичный баг из #9

localizeRows был переписан с async* на StreamController, но yield row выкинули, а controller.add(row) не добавили. Стрим закрывался, ни разу ничего не выдав → updateSheet не вызывался никогда. С момента мержа #9 localize переводил всё и не писал в таблицу ничего. Починено, покрыто тестом.

Проблема 1 — uk воспринимается как United Kingdom

Хэш-таблица была (0.4.1), но она добавляла uk (Ukrainian) в одну строку промта, и модель это игнорировала. Теперь код языка расшифровывается в трёх местах сразу:

  • в промте: uk — Ukrainian (українська) — Ukrainian (Cyrillic script). NOT English and NOT "United Kingdom" — British English would be "en_GB".
  • правило: ключи — это language коды, а не коды стран; писать в родном письме языка; никогда не отвечать по-английски на неанглийский код;
  • в самой JSON-схеме, в description поля text, где модель физически не может это пропустить.

Добавлены эндонимы (~70 языков) и заметки-предупреждения для ~25 реально путаемых кодов (cs/ce, sl/sk, ja/jv, ka/kn, ms/ml, se/sv, …).

Проблема 2 — редкие языки возвращают билиберду и ломают соседей

Упавший батч теперь разбивается, а не ретраится: каждый язык из него уходит отдельным запросом. Языки, которые уже перевелись в батче, сохраняются и повторно не запрашиваются; язык, упавший в одиночку, оставляет свою ячейку пустой (подхватится на следующем прогоне), а соседи всё равно пишутся в таблицу.

Скорее всего, у билиберды была конкретная причина: max_output_tokens был захардкожен в 2048 вне зависимости от размера батча, и батч из редких языков (которые плохо токенизируются) обрезался посреди JSON — ответ приходит с status: incomplete и парсится как мусор. Теперь бюджет токенов масштабируется от числа языков, а обрезанный ответ детектируется явно, а не скармливается в jsonDecode.

Что ещё улучшено

  • Жёсткий таймаут запроса (--timeout, по умолчанию 120с) — раньше зависший запрос держал воркер вечно.
  • Ретраи только на транзиентных ошибках (сеть, таймаут, 429, 5xx). Непарсибельный ответ никогда не отправляется тем же промтом повторно — вместо этого работает разбиение.
  • Валидация перевода до записи: ICU-плейсхолдеры ({name}) и теги разметки должны сохраниться, пустые значения запрещены, никаких markdown-фенсов и разбегающегося вывода. Отклонённый перевод ретраится в одиночку — страдает только он.
  • Строка, которую не удалось записать в таблицу, пропускается, а не роняет весь прогон; частично локализованные строки всё равно сохраняются.

Структура и тесты

Пайплайн переехал из bin/localize.dart в lib/src/localize/bin/ остались CLI и работа с Google Sheets) — именно это и делает его тестируемым.

58 тестов в семи файлах: таблица языков, сборка промта, правила валидации, извлечение ячеек, логика разбиения батчей на фейковом клиенте и HTTP-тесты OpenAI-клиента против локального стенда (ретрай на 500, отсутствие ретрая на 401 и на битом payload, таймаут, лимит параллелизма).

Закомментированный шаг тестов в CI включён; format/analyze теперь покрывают lib/ и test/, а не только bin/. Dockerfile копирует lib/.

🤖 Generated with Claude Code

PlugFox and others added 8 commits July 14, 2026 16:08
… failing batches

PR #9 rewrote localizeRows() from an async* generator into a StreamController
but dropped the `yield row` without adding a `controller.add(row)`, so the
stream closed without ever emitting: updateSheet() was never called and no
translation reached the spreadsheet.

On top of that fix, harden the pipeline against the model itself:

- Locale codes are spelled out for the model in both the prompt and the JSON
  schema — English name, native endonym, and an explicit note for codes that
  are routinely misread ("uk" is Ukrainian, not United Kingdom).
- A failed batch of languages is split into single-language requests instead
  of being re-sent as a whole, so a rare language the model chokes on cannot
  break its neighbours. Unusable payloads are never retried with the same
  prompt; only transient failures (network, timeout, 429, 5xx) are.
- Every translation is validated before it is written: ICU placeholders and
  markup tags must survive, no empty values, no markdown fences, no runaway
  output. A rejected translation is retried alone.
- Requests get a hard timeout (--timeout, default 120s) and a token budget
  that scales with the batch size, so the model can neither hang a worker nor
  truncate its answer into invalid JSON.
- A row that cannot be written to the sheet is skipped instead of aborting
  the whole run; partially localized rows are still saved.

The pipeline moved from bin/ to lib/ and is now covered by 58 unit tests,
including HTTP-level tests against a local stand-in for the OpenAI API.
The test step in CI is enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sheets

Two failures found by running the pipeline against the real spreadsheet.

1. gpt-5-mini rejects the sampling parameters outright:
   `400 Unsupported parameter: 'temperature' is not supported with this
   model`. Every request failed this way, so localize had been writing
   nothing at all since gpt-5-mini became the default in 0.4.2. Reasoning
   models (gpt-5*, o*) now get `reasoning: {effort: low}` instead of
   temperature/top_p, and their token budget reserves headroom for the
   reasoning tokens, which are billed against the same max_output_tokens
   ceiling and would otherwise truncate the answer into invalid JSON.

2. A sheet was localized regardless of its shape. A spreadsheet also holds
   reference tables whose columns are ordinary data, and localize happily
   "translated" them, overwriting the data with model output. A sheet is now
   localized only when its header matches the documented layout
   `label | description | meta | en | <locale> ...`; anything else is skipped
   with a warning, independently of the --ignore flag.

Also regenerates example/ from the current spreadsheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ate example

Both tasks carry the flags the project actually runs with, in particular an
--ignore list that keeps the reference sheets (locales, help, backend-*,
telegram-*) out of the pipeline.

example/ is regenerated with the same flags: the ARBs move to src/l10n, use
the "app" prefix and drop @@last_modified, so a rerun no longer rewrites every
file with a new timestamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… semaphore leak

Findings of an adversarial review of the branch.

- Placeholder validation rejected every correctly translated plural. The
  regex `\{[^{}]*\}` cannot cross nested braces, so on
  `{count, plural, one {# message} other {# messages}}` it matched the branch
  bodies — the very text meant to be translated — instead of the argument.
  A correct German translation therefore "lost" the placeholders, was retried
  alone, was rejected again and the cell was left empty forever. The same hole
  silently accepted a translation that had flattened the directive away. The
  scanner now matches braces by depth: a plural is ONE argument whose branches
  are ordinary text, argument names are compared as a set (plural categories
  legitimately differ per language: two in English, four in Russian, one in
  Japanese), and a lost `plural`/`select` directive is caught explicitly.
  The sheets of this project do ship such strings.

- parseResponseBody accepted any output item with a `content` list. Reasoning
  models may put their chain of thought in exactly that shape, ahead of the
  message, so the thinking text was jsonDecode'd as the payload and every
  request failed non-retryably. Only `message` items and their `output_text`
  parts are read now; a refusal is reported as a refusal.

- The semaphore slot was acquired outside the try, and released after
  `client.close()`. A throw from either would leak the slot; `workers` such
  leaks starve every worker and hang the run with no output. Release now
  happens in an outer finally that nothing can bypass.

- `gpt-5-chat-latest` is not a reasoning model — it accepts temperature and
  rejects `reasoning.effort` — but the `gpt-5` prefix swept it up, so every
  request would 400. Chat variants and the pre-reasoning o1 models are
  excluded. The reasoning token reserve is raised to 16k: it is a ceiling, not
  a charge, and an exhausted one truncates the answer into invalid JSON.

- Two columns whose headers sanitize to the same locale (`pt-BR` / `pt_BR`,
  or a trailing space) made the pipeline ask for the language twice and write
  the answer into the first column only, leaving the second empty forever. The
  duplicate column is now skipped with a warning.

- An error delivered inside a 200 body is no longer retried as if it were a
  transient network failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comparing argument names as a set kept the directive check honest across
languages with different plural categories, but it could not see a translation
that keeps `{value, plural, ...}` and drops `{value}` from the branch bodies —
the number simply never renders ("лет" instead of "5 лет"). Branch arguments
are now collected separately and compared on their own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he concurrency test bite

Findings of a review of the tests, the CLI and the CI.

- The concurrency test was vacuous: the fake OpenAI server awaited each handler
  inside its accept loop, so it served requests one at a time and could never
  observe a client exceeding its limit. Deleting the semaphore entirely kept
  the suite green. The fake now serves concurrently, and the test asserts the
  peak is exactly `workers` — an upper bound alone would also be satisfied by a
  client stuck at one request. A second test covers slot release after a failed
  request, the leak that would otherwise hang the whole run.

- A sheet title with a space or an apostrophe ("App Strings") built an invalid
  bare A1 range, so the API rejected the write with 400 and the row was lost.
  Titles are quoted now, with inner quotes doubled.

- The sheet writer retried every failure 3x with a fixed 30s sleep, including
  the permanent ones (bad range, missing scope, deleted sheet). Since writes are
  sequential, one doomed row stalled every row behind it for a minute. Failures
  are classified now — only 429/5xx/network are retried, with exponential
  backoff.

- --sheet was declared mandatory, so args threw before the friendly "Missing
  required argument" check could ever run. A bad numeric option (--workers=abc,
  --batch=99) was silently swallowed into the default; it warns now.

- The system prompt and the in-body error path had no coverage: both could be
  deleted with the suite still green.

- CHANGELOG did not mention the most disruptive change of 0.5.0 — that a sheet
  is now localized only if its header matches the documented layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r it with tests

The sheet-write path — the exact code where the PR#9 regression lived — was
bound to SheetsApi and a global rate limiter, so it was reachable only through
a live Google account and had no tests at all.

- SheetsGateway is the interface the pipeline talks to; GoogleSheetsGateway is
  its only Sheets-aware implementation and the only place that knows about
  googleapis. Its DetailedApiRequestError is mapped onto SheetsException, whose
  status code carries the retry decision.
- updateRow (range building, batching, failure classification, retries) and
  RateLimiter (injectable clock and sleep, serialized so the check and the
  reservation cannot interleave) moved to lib/ as ordinary testable code.
- bin/localize.dart keeps only argument parsing, authentication and wiring.

17 new tests cover what could not be tested before: A1 quoting for titles with
spaces and apostrophes, one batch per row, empty cells skipped, retry on
429/5xx/transport, immediate give-up on 400/403/404, the run surviving an
unwritable row, and the rate-limiter window. Mutating the failure
classification kills a test; mutating the range quoting kills three.

Also completes the 0.5.0 changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--ignore takes regular expressions, not globs. "telegram-*" reads as "telegram
followed by any number of dashes" and only matched telegram-monetization by
accident, as a substring; "backend-*" was redundant next to "backend", which
already matches unanchored. The tasks now use anchored regexes, and the README
says outright that these are RegExps and that a glob-looking pattern will not
do what it looks like it does.

Verified: the new patterns skip the same six sheets and regenerate example/
byte for byte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mike-doctorina
mike-doctorina merged commit 32f7b32 into master Jul 14, 2026
1 check passed
@mike-doctorina
mike-doctorina deleted the fix/localization-robustness branch July 14, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants