Перейти к основному содержимому

ADR 0177 — Язык ответов живёт в трёх местах — учётная запись, сессия и проект, — в открытом списке BCP-47 и никогда в users.locale

Статус​

Accepted. 2026-09-28 (AT-162, эпик EP-028). Решения сопровождающего от 2026-09-28, записанные в заметках AT-168 и AT-169: предпочтение по УЧЁТНОЙ ЗАПИСИ с переопределением по СЕССИИ; ОТКРЫТЫЙ список языков (любой BCP-47); новая учётная запись в automatico; обнаруженный действует только ПОДТВЕРЖДЁННЫМ; общие артефакты и ходы без автора — на языке ПРОЕКТА.

Ссылается, не редактируя, на ADR 0121 (новая таблица входит в файл своего агрегата) и ADR 0006 (системный prompt принадлежит АГЕНТУ, а не автору хода, — поэтому язык не входит в него, а входит в ориентацию на ход, которая принадлежит AT-164).

Контекст​

Язык ответа агента до сих пор решала лишь модель, исходя из системного prompt, написанного на pt-BR, и из сообщений. Сообщение, которое открыло эпик, — разговор на португальском, на который ответили на испанском, с DeepSeek через OpenRouter. users.locale (RN-432) существует, но это язык ИНТЕРФЕЙСА: закрыт до pt-BR/en, потому что каждому значению нужен файл перевода, и engine его нигде не читает.

Решения сопровождающего требуют четырёх источников, которых не было, и одного, который был, с приоритетом между ними и тремя разными областями:

  • явный выбор принадлежит ЧЕЛОВЕКУ и действует во всех проектах и сессиях;
  • человек может закрепить ДРУГОЙ язык в сессии, только для себя, — два пользователя в одной сессии не задевают друг друга;
  • обнаруженный по сообщениям принадлежит ЧЕЛОВЕКУ, глобален и учитывается лишь после того, как он подтвердит;
  • то, что принадлежит всем (общий артефакт, ход без автора), следует ПРОЕКТУ.

Решение​

  1. Никакого enum. Три места хранят text с КАНОНИЧЕСКИМ кодом BCP-47, проверяемым в api функцией normalizarIdiomaBcp47 (apps/api/src/domain/iam/idioma-de-resposta.ts): форма через Intl.getCanonicalLocales, подтег языка, известный ICU процесса (Intl.DisplayNames с fallback: 'none'), никогда und и частного использования, не более 35 символов. Открытие языка ОТВЕТА не зависит ни от какого файла ресурсов; enum требовал бы миграции на каждый язык, а это цена, которую решение "открытый список" отвергло.
  2. Учётная запись: две колонки в users, а locale нетронут. users.response_language — NULL означает АВТОМАТИЧЕСКИЙ, и так рождается каждая учётная запись (без значения-сигнала внутри колонки языков). И пара users.detected_language + users.detected_language_confirmed_at, с CHECK, требующим их вместе: обнаруженный существует лишь ПОДТВЕРЖДЁННЫМ. Пару записывает AT-163 (обнаружение и вопрос подтверждения); колонка рождается здесь, потому что это ступень приоритета.
  3. Сессия: таблица session_language_overrides в агрегате сессий, с ключом {session_id, user_id}. Конфигурация, а не событие: повторное закрепление — upsert, снятие — DELETE, а удалённая сессия уносит строки. Ключ — ПАРА, потому что переопределение принадлежит человеку; чтения "всей сессии" нет.
  4. Проект: колонка projects.language (реализована в AT-243, со значением по умолчанию и минимальной ролью, решёнными там, в RN-619). Это язык того, у чего нет человека-автора.
  5. Приоритет для того, у кого есть автор (RN-618), разрешаемый по ЧЕЛОВЕКУ и никогда по сессии: переопределение сессии > выбор учётной записи > подтверждённый обнаруженный > users.locale. Поскольку users.locale — NOT NULL, цепочка всегда заканчивается языком. "Точечная просьба" внутри сообщения — не ступень: её выполняет модель, благодаря формулировке ориентации.
  6. Одна точка разрешения. ResolverIdiomaDaRespostaUseCase возвращает победителя, ИСТОЧНИК и всю цепочку; это то, что показывает экран (Conta, панель сессии), и то, что вызывает AT-164, чтобы передать язык в engine.

Последствия​

  • users.locale остаётся закрытым и по-прежнему принадлежит лишь интерфейсу: ни выбор ответов, ни обнаружение в него не пишут. Страница Conta показывает оба рядом, а маршрут PATCH /users/me/preferences принимает оба поля как НЕОБЯЗАТЕЛЬНЫЕ и независимые.
  • Список принимаемых языков — это список ICU того Node, на котором работает api. Редкий код ISO 639-3, который ICU не называет, отклоняется, объявлено, — ни один правдоподобный язык ответа под этот случай не попадает.
  • В engine пока нет потребителя: ни один ход не меняет поведения с этим ADR. Транспорт (эфемерная ориентация на ход) — это AT-164; обнаружение и подтверждение — AT-163. До тех пор выбор языка в Conta записывается и показывается, но ещё не доходит до модели — экран не утверждает обратного.
  • Переопределение по сессии разрешено в закрытой сессии: оно не запускает ход и не пишет в event log.