@happyvertical/smrt-languages 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,81 @@
1
+ # languages
2
+
3
+ SMRT language string registry with file/config + tenant overrides and AI-driven
4
+ auto-translation for missing locales.
5
+
6
+ ## Core pieces
7
+
8
+ - `defineLanguageString({ key, locale, template })` registers a code default in
9
+ a global process registry. Same shape as `definePrompt` but keyed by
10
+ `(key, locale)` rather than `key` alone.
11
+ - `resolveLanguageString(key, options)` walks the 5-layer chain (code → file
12
+ config → app override → tenant override → runtime override) and falls back
13
+ through the locale chain (`fr-CA` → `fr` → default) before giving up.
14
+ - `LanguageOverride` stores app-level and tenant-level overrides in
15
+ `_smrt_language_overrides`. `auto_generated`, `source_hash`, `ai_model`,
16
+ `reviewed_at`, `reviewed_by` fields support the AI auto-translation pipeline
17
+ and admin review queue.
18
+ - `enqueueTranslationJob({ key, targetLocale })` writes a `LanguageTranslationTask`
19
+ job into the `languages` queue with a deterministic dedup ID so concurrent
20
+ resolver misses collapse into one job.
21
+
22
+ ## Locale-miss flow
23
+
24
+ When `resolveLanguageString` cannot find an exact `(key, locale, tenantId)`:
25
+
26
+ 1. Walk the locale fallback chain (`buildLocaleFallbackChain`).
27
+ 2. Return the first hit — same call returns immediately with
28
+ `source: 'fallback'`.
29
+ 3. Fire-and-forget `enqueueTranslationJob` for the missing target, scoped to
30
+ the current tenant for glossary purposes. App-level translations are
31
+ reusable across tenants, so the resulting `LanguageOverride` row is written
32
+ with `tenantId: null`.
33
+ 4. Subsequent requests hit the new app-level row and resolve at the requested
34
+ locale.
35
+
36
+ The translation job:
37
+
38
+ - Honors the `smrt-languages.auto_translate` feature flag (kill switch).
39
+ - Skips locales outside `supportedLocales` when configured.
40
+ - Skips when `LanguageOverride` already exists with a matching `source_hash`
41
+ (re-translation is hash-gated, never time-based).
42
+ - Never overwrites a row with `auto_generated: false` — human edits win
43
+ permanently.
44
+ - Pulls the tenant's existing overrides and renders them as a glossary so
45
+ auto-translations match tenant voice.
46
+
47
+ ## Conventions
48
+
49
+ - Keys are namespaced by package: `users.role.member`, `commerce.invoice.dueText`.
50
+ - Locales follow BCP-47 (`en`, `fr-CA`, `pt-BR`) and are normalized to
51
+ lowercase-language / uppercase-region on persistence.
52
+ - The translation prompt itself is registered with `smrt-prompts` under
53
+ `smrt-languages.translation` so ops can tune wording without redeploying.
54
+ - `context` column on `_smrt_language_overrides` is set to `tenantId` or
55
+ `'__app__'` so the `(key, locale, context)` upsert key remains unique even
56
+ with nullable `tenantId`.
57
+
58
+ ## Public API surface
59
+
60
+ ```typescript
61
+ import {
62
+ defineLanguageString,
63
+ resolveLanguageString,
64
+ LanguageOverride,
65
+ LanguageOverrideCollection,
66
+ clearLanguageCache,
67
+ } from '@happyvertical/smrt-languages';
68
+
69
+ defineLanguageString({
70
+ key: 'users.role.member',
71
+ locale: 'en',
72
+ template: 'Member',
73
+ });
74
+
75
+ const text = await resolveLanguageString('users.role.member', {
76
+ db,
77
+ tenantId: 'tenant-a',
78
+ locale: 'es',
79
+ vars: { name: 'Will' },
80
+ });
81
+ ```
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @happyvertical/smrt-languages
2
+
3
+ Code-first language strings with config + tenant overrides and AI-driven
4
+ auto-translation for SMRT applications.
5
+
6
+ `smrt-languages` mirrors the architecture of `@happyvertical/smrt-prompts`:
7
+ packages declare their user-facing strings as code, applications and tenants
8
+ override them through file config or DB rows, and the resolver merges every
9
+ layer at runtime. v1 adds an automatic AI-translation step backed by
10
+ `@happyvertical/smrt-jobs`: the first time a string is requested in a locale
11
+ that has neither a code default nor an override, the resolver returns a
12
+ fallback locale immediately and enqueues a background translation. Subsequent
13
+ requests hit the new app-level row.
14
+
15
+ This package is a Phase 2 prerequisite of the broader package adoption epic —
16
+ issue [#1200](https://github.com/happyvertical/smrt/issues/1200).
17
+
18
+ ## Why
19
+
20
+ - Today, packages hard-code labels like `'Member'`, `'Article'`, or
21
+ `'Payment due by {dueDate}'`. There is no way for a tenant to use
22
+ `'Subscriber'` instead of `'Member'` without forking, and no way for a
23
+ tenant to operate in their own language.
24
+ - `smrt-prompts` already proved the layered-override pattern for AI prompts.
25
+ `smrt-languages` applies the same pattern to display strings, plus an
26
+ AI-driven gap-filler that closes the loop on missing locales without
27
+ blocking on the first request.
28
+
29
+ ## Quick start
30
+
31
+ ```typescript
32
+ import {
33
+ defineLanguageString,
34
+ resolveLanguageString,
35
+ } from '@happyvertical/smrt-languages';
36
+
37
+ // Define defaults at startup, typically in your package's
38
+ // __smrt-register__.ts so the registry is populated before resolves run.
39
+ defineLanguageString({
40
+ key: 'users.role.member',
41
+ locale: 'en',
42
+ template: 'Member',
43
+ });
44
+
45
+ defineLanguageString({
46
+ key: 'commerce.invoice.dueText',
47
+ locale: 'en',
48
+ template: 'Payment due by {dueDate}',
49
+ });
50
+
51
+ // Resolve at runtime. Tenant context is read from AsyncLocalStorage by default.
52
+ const text = await resolveLanguageString('users.role.member', {
53
+ db,
54
+ locale: 'es',
55
+ vars: { dueDate: '2026-06-01' },
56
+ // strict: false → return the English fallback and enqueue an AI translation.
57
+ // strict: true → throw when no resolution exists.
58
+ });
59
+ ```
60
+
61
+ ## Resolution layers
62
+
63
+ In ascending priority:
64
+
65
+ 1. **Code default** — `defineLanguageString({ key, locale, template })`
66
+ 2. **File/config override** — `getPackageConfig('languages').overrides[key][locale]`
67
+ 3. **App-level stored override** — `LanguageOverride` row with `tenantId = null`
68
+ 4. **Tenant-level stored override** — `LanguageOverride` row with `tenantId = <current>`
69
+ 5. **Runtime override** — `resolveLanguageString(key, { overrides: { template: '...' } })`
70
+
71
+ When the requested `(key, locale)` doesn't exist anywhere, the resolver walks
72
+ a fallback chain — `fr-CA` → `fr` → registered default-locale (`en`) — and
73
+ returns the first hit. Whenever the hit is at a different locale than what
74
+ was requested, an AI translation job is enqueued for the original target.
75
+
76
+ ## Storage
77
+
78
+ Overrides live in `_smrt_language_overrides`:
79
+
80
+ | Column | Notes |
81
+ |--------|-------|
82
+ | `key` | Namespaced string key (`users.role.member`) |
83
+ | `locale` | BCP-47 tag (`en`, `fr-CA`) |
84
+ | `tenantId` | `null` for app-level, tenantId for tenant-level |
85
+ | `template` | The override string with `{var}` placeholders |
86
+ | `auto_generated` | `true` when produced by the AI translation job |
87
+ | `source_hash` | sha256 of the source template at translation time |
88
+ | `ai_model` | Model identifier; `null` for human-edited rows |
89
+ | `reviewed_at` / `reviewed_by` | Set when an admin approves an auto row |
90
+
91
+ Source-hash gating means re-translation only happens when the source actually
92
+ changes — auto-generated rows whose `source_hash` matches are left alone.
93
+ Human-edited rows (`auto_generated: false`) are **never** overwritten.
94
+
95
+ ## AI auto-translation
96
+
97
+ When a `(key, targetLocale)` is missed, the resolver enqueues a
98
+ `LanguageTranslationTask` job into the `languages` queue with a deterministic
99
+ dedup ID — `smrt-languages.translate:<key>:<targetLocale>` — so concurrent
100
+ misses collapse into a single job. The job:
101
+
102
+ 1. Reads the tenant's existing language overrides as a glossary (no-op when
103
+ no tenant context).
104
+ 2. Calls `@happyvertical/ai` with a low-temperature translation prompt that
105
+ itself is registered via `smrt-prompts` under
106
+ `smrt-languages.translation` — operators can tune the wording without
107
+ redeploying.
108
+ 3. Validates the response (non-empty, no obvious markup leaks).
109
+ 4. Upserts an app-level `LanguageOverride` row with `auto_generated: true`,
110
+ `source_hash`, and `ai_model`.
111
+ 5. Invalidates the resolver cache for `(key, targetLocale, *)`.
112
+
113
+ ### Cost & abuse controls
114
+
115
+ - `smrt-features` flag `smrt-languages.auto_translate` — global / per-tenant
116
+ kill switch.
117
+ - `translationBudgetPerTenantPerDay` — daily cap per tenant.
118
+ - `supportedLocales` — optional allowlist; jobs for other locales are dropped
119
+ before any AI call.
120
+ - Source-hash gating prevents re-translation when nothing changed.
121
+
122
+ ### Admin review
123
+
124
+ ```bash
125
+ smrt languages translate --locales=es,fr,de # batch eager pre-population
126
+ smrt languages review --locale=es # list unreviewed auto rows
127
+ smrt languages approve <id> # mark reviewed
128
+ smrt languages edit <id> --template "..." # edit + flip auto_generated to false
129
+ ```
130
+
131
+ CLI surfaces are auto-generated by SMRT from the `LanguageOverride` model and
132
+ helpers in `src/cli.ts`.
133
+
134
+ ## Configuration
135
+
136
+ `smrt.config.{js,ts,json}`:
137
+
138
+ ```js
139
+ export default {
140
+ packages: {
141
+ languages: {
142
+ defaultLocale: 'en',
143
+ supportedLocales: ['en', 'es', 'fr', 'de', 'ja'],
144
+ translationBudgetPerTenantPerDay: 200,
145
+ overrides: {
146
+ 'users.role.member': {
147
+ es: 'Miembro',
148
+ },
149
+ },
150
+ },
151
+ },
152
+ };
153
+ ```
154
+
155
+ ## Subpath exports
156
+
157
+ The package root (`@happyvertical/smrt-languages`) only exposes the read path:
158
+ `defineLanguageString`, `resolveLanguageString`, `LanguageOverride`, the cache
159
+ helpers, the glossary helper, and shared types/utilities. Loading the root
160
+ does **not** pull `@happyvertical/ai`, smrt-jobs, smrt-features, or
161
+ smrt-prompts into the consumer bundle.
162
+
163
+ The translation-worker stack lives on a subpath:
164
+
165
+ ```typescript
166
+ // Background workers + tests that enqueue or run translation jobs.
167
+ import {
168
+ enqueueTranslationJob,
169
+ LanguageTranslationTask,
170
+ AUTO_TRANSLATE_FEATURE_KEY,
171
+ TRANSLATION_PROMPT_KEY,
172
+ } from '@happyvertical/smrt-languages/jobs';
173
+
174
+ // Admin / batch CLI helpers.
175
+ import {
176
+ translateMissing,
177
+ approveAutoTranslation,
178
+ editLanguageOverride,
179
+ listUnreviewedAutoTranslations,
180
+ } from '@happyvertical/smrt-languages/cli';
181
+ ```
182
+
183
+ The resolver itself dynamically imports the worker module on a soft-miss, so
184
+ calling `resolveLanguageString` still triggers a translation enqueue without
185
+ the caller having to pre-import the `/jobs` subpath.
186
+
187
+ ## Out of scope (v1)
188
+
189
+ Pluralization, ICU MessageFormat, Svelte component i18n, RTL layout, locale
190
+ negotiation HTTP middleware, XLIFF/PO TM-tool integration, and richer quality
191
+ scoring of AI translations are all v1.1+ concerns. v1 sticks to plain
192
+ `{var}` substitution and the resolution chain above so adoption stays a
193
+ mechanical refactor across consumer packages.