跳到主要内容

ADR 0177 — 回答的语言存放在三个地方——账户、会话和项目——采用开放的 BCP-47 列表,绝不放在 users.locale 中

状态​

已接受。 2026-09-28(AT-162,史诗 EP-028)。维护者于 2026-09-28 的决定,写在 AT-168 和 AT-169 的笔记中:按账户的偏好,可按会话覆盖;开放的语言列表(任意 BCP-47);新账户为 automatico;检测到的语言只有在确认后才生效;共享构件和没有作者的回合使用项目的语言。

引用(不编辑)ADR 0121(新表进入其聚合的文件)和 ADR 0006(系统 prompt 属于 agent,而不属于回合的作者——这就是为什么语言不进入系统 prompt,而是进入按回合的指引,那是 AT-164 的事)。

背景​

到目前为止,agent 回答的语言只由模型根据一个用 pt-BR 写的系统 prompt 和消息来决定。开启这个史诗的报告是一次用葡萄牙语进行、却以西班牙语回答的对话,使用的是经由 OpenRouter 的 DeepSeek。users.locale(RN-432)存在,但它是界面的语言:限定为 pt-BR/en,因为每个值都需要翻译文件,而且 engine 在任何地方都不读取它。

维护者的决定要求四个之前不存在的来源和一个已存在的来源,它们之间有优先级,以及三种不同的范围:

  • 显式的选择属于人,在每个项目和会话中都有效;
  • 这个人可以在某个会话中固定另一种语言,只对自己有效——同一会话中的两个用户互不影响;
  • 从消息中检测到的语言属于人,全局有效,只有在其确认之后才算数;
  • 属于所有人的东西(共享构件、没有作者的回合)跟随项目。

决策​

  1. 没有枚举。 三个地方都保存带规范 BCP-47 代码的 text,在 api 中由 normalizarIdiomaBcp47(apps/api/src/domain/iam/idioma-de-resposta.ts)验证:形式由 Intl.getCanonicalLocales 决定,语言子标签须为进程的 ICU 所知(Intl.DisplayNames 带 fallback: 'none'),绝不是 und 或私用,最多 35 个字符。开放回答的语言不依赖任何资源文件;枚举会让每种语言都需要一次迁移,而这正是“开放列表”这一决定所拒绝的成本。
  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 返回胜出者、来源以及整条链;这就是界面显示的内容(账户页、会话栏),也是 AT-164 调用来把语言发送给 engine 的东西。

后果​

  • users.locale 仍然是封闭的,仍然只属于界面:回答的选择和检测都不写入它。账户页面把两者并排显示,路由 PATCH /users/me/preferences 把两个字段作为可选且独立的字段接受。
  • 可接受的语言列表就是运行 api 的 Node 的 ICU 的列表。ICU 不命名的罕见 ISO 639-3 代码会被拒绝,这已声明——没有任何合理的回答语言属于这种情况。
  • engine 中还没有使用者:没有任何回合因本 ADR 而改变行为。传输(按回合的临时指引)是 AT-164;检测和确认是 AT-163。在那之前,在账户中选择一种语言会被保存并显示,但还不会到达模型——界面不会断言相反的情况。
  • 在已结束的会话中允许按会话覆盖:它不触发回合,也不写入事件日志。