Compare commits

..

7 Commits

Author SHA1 Message Date
2925aaac7d docs(phase-7): complete Multilanguage phase execution 2026-03-25 17:12:28 -06:00
b5709d9549 docs(07-04): complete multilanguage verification plan
- Human verification approved for all 6 I18N requirements
- Portal confirmed rendering correctly in EN/ES/PT
- Language switcher, persistence, and AI Employee language response verified
- Phase 7 (multilanguage) marked complete in ROADMAP.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:06:58 -06:00
6e9441215b docs(07-03): complete portal i18n string extraction plan 2026-03-25 17:05:08 -06:00
1018269f82 docs(07-02): complete frontend i18n infrastructure plan
- Create 07-02-SUMMARY.md with full execution details
- Update STATE.md with position, decisions, metrics
- Update ROADMAP.md progress (Phase 7: 2/4 plans complete)
- Mark requirements I18N-01, I18N-02 complete in REQUIREMENTS.md
2026-03-25 16:30:37 -06:00
1b69ea802e docs(07-01): complete backend multilanguage foundation plan 2026-03-25 16:29:03 -06:00
9654982433 feat(07-01): localized emails, locale-aware templates API, language preference endpoint
- email.py: send_invite_email() adds language param (en/es/pt), sends localized subject+body
- templates.py: list_templates()/get_template() accept ?locale= param, merge translations on response
- portal.py: PATCH /api/portal/users/me/language endpoint persists language preference
- portal.py: /api/portal/auth/verify response includes user.language field
- portal.py: AuthVerifyResponse adds language field (default 'en')
- test_portal_auth.py: fix _make_user mock to set language='en' (auto-fix Rule 1)
- test_language_preference.py: 4 integration tests for language preference endpoint
- test_templates_i18n.py: 5 integration tests for locale-aware templates (all passing)
2026-03-25 16:27:14 -06:00
7a3a4f0fdd feat(07-01): DB migration 009, ORM updates, and LANGUAGE_INSTRUCTION in system prompts
- Migration 009: adds language col (VARCHAR 10, NOT NULL, default 'en') to portal_users
- Migration 009: adds translations col (JSONB, NOT NULL, default '{}') to agent_templates
- Migration 009: backfills es+pt translations for all 7 seed templates
- PortalUser ORM: language mapped column added
- AgentTemplate ORM: translations mapped column added
- system_prompt_builder.py: LANGUAGE_INSTRUCTION constant + appended before AI_TRANSPARENCY_CLAUSE
- system-prompt-builder.ts: LANGUAGE_INSTRUCTION constant + appended before AI transparency clause
- tests: TestLanguageInstruction class with 3 tests (all pass, 20 total)
2026-03-25 16:22:53 -06:00
20 changed files with 1857 additions and 60 deletions

View File

@@ -74,12 +74,12 @@ Requirements for beta-ready release. Each maps to roadmap phases.
### Multilanguage
- [ ] **I18N-01**: Portal UI fully localized in English, Spanish, and Portuguese (all pages, labels, buttons, error messages)
- [ ] **I18N-02**: Language switcher accessible from anywhere in the portal — selection persists across sessions
- [ ] **I18N-03**: AI Employees detect user language and respond accordingly, or use a language configured per agent
- [ ] **I18N-04**: Agent templates, wizard steps, and onboarding flow are fully translated in all three languages
- [ ] **I18N-05**: Error messages, validation text, and system notifications are localized
- [ ] **I18N-06**: Adding a new language requires only translation files, not code changes (extensible i18n architecture)
- [x] **I18N-01**: Portal UI fully localized in English, Spanish, and Portuguese (all pages, labels, buttons, error messages)
- [x] **I18N-02**: Language switcher accessible from anywhere in the portal — selection persists across sessions
- [x] **I18N-03**: AI Employees detect user language and respond accordingly, or use a language configured per agent
- [x] **I18N-04**: Agent templates, wizard steps, and onboarding flow are fully translated in all three languages
- [x] **I18N-05**: Error messages, validation text, and system notifications are localized
- [x] **I18N-06**: Adding a new language requires only translation files, not code changes (extensible i18n architecture)
## v2 Requirements
@@ -170,12 +170,12 @@ Which phases cover which requirements. Updated during roadmap creation.
| CHAT-03 | Phase 6 | Complete |
| CHAT-04 | Phase 6 | Complete |
| CHAT-05 | Phase 6 | Complete |
| I18N-01 | Phase 7 | Pending |
| I18N-02 | Phase 7 | Pending |
| I18N-03 | Phase 7 | Pending |
| I18N-04 | Phase 7 | Pending |
| I18N-05 | Phase 7 | Pending |
| I18N-06 | Phase 7 | Pending |
| I18N-01 | Phase 7 | Complete |
| I18N-02 | Phase 7 | Complete |
| I18N-03 | Phase 7 | Complete |
| I18N-04 | Phase 7 | Complete |
| I18N-05 | Phase 7 | Complete |
| I18N-06 | Phase 7 | Complete |
**Coverage:**
- v1 requirements: 25 total (all complete)

View File

@@ -141,7 +141,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7
| 4. RBAC | 3/3 | Complete | 2026-03-24 |
| 5. Employee Design | 4/4 | Complete | 2026-03-25 |
| 6. Web Chat | 3/3 | Complete | 2026-03-25 |
| 7. Multilanguage | 0/4 | Not started | - |
| 7. Multilanguage | 4/4 | Complete | 2026-03-25 |
---

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: completed
stopped_at: Phase 7 context gathered
last_updated: "2026-03-25T21:52:25.174Z"
stopped_at: Completed 07-multilanguage-04-PLAN.md
last_updated: "2026-03-25T23:12:21.218Z"
last_activity: 2026-03-23 — Completed 03-02 onboarding wizard, Slack OAuth, BYO API keys
progress:
total_phases: 7
completed_phases: 6
total_plans: 25
completed_plans: 25
completed_phases: 7
total_plans: 29
completed_plans: 29
percent: 100
---
@@ -77,6 +77,10 @@ Progress: [██████████] 100%
| Phase 06-web-chat P01 | 8min | 2 tasks | 11 files |
| Phase 06-web-chat PP02 | 6min | 2 tasks | 10 files |
| Phase 06-web-chat P03 | verification | 1 tasks | 0 files |
| Phase 07-multilanguage P01 | 7min | 2 tasks | 12 files |
| Phase 07-multilanguage P02 | 9min | 2 tasks | 14 files |
| Phase 07-multilanguage P03 | 45min | 2 tasks | 48 files |
| Phase 07-multilanguage P04 | verification | 1 tasks | 0 files |
## Accumulated Context
@@ -168,6 +172,15 @@ Recent decisions affecting current work:
- [Phase 06-web-chat]: useSearchParams wrapped in Suspense boundary — Next.js 16 static prerendering requires Suspense for pages using URL params
- [Phase 06-web-chat]: Stable callback refs in useChatSocket — onMessage/onTyping held in refs so WebSocket effect re-runs only when conversationId or auth changes
- [Phase 06-web-chat]: All CHAT requirements (CHAT-01 through CHAT-05) verified by human testing before Phase 6 marked complete
- [Phase 07-multilanguage]: LANGUAGE_INSTRUCTION appended before AI_TRANSPARENCY_CLAUSE — transparency clause remains last (non-negotiable per Phase 1)
- [Phase 07-multilanguage]: Translation overlay at response time (not stored) — English values never overwritten in DB
- [Phase 07-multilanguage]: auth/verify response includes language field — Auth.js JWT can carry it without additional per-request DB queries
- [Phase 07-multilanguage]: PortalUser.language server_default='en' — existing users get English without data migration
- [Phase 07-multilanguage]: i18n/locales.ts created to separate client-safe constants from server-only i18n/request.ts (next/headers import)
- [Phase 07-multilanguage]: Cookie name konstruct_locale for cookie-based locale with no URL routing
- [Phase 07-multilanguage]: LanguageSwitcher isPreAuth prop skips DB PATCH and session.update() on login page
- [Phase 07-multilanguage]: onboarding/page.tsx uses getTranslations() not useTranslations() — Server Component requires next-intl/server import
- [Phase 07-multilanguage]: billing-status.tsx trialEnds key uses only {date} param — boolean ICU params rejected by TypeScript strict mode
### Roadmap Evolution
@@ -183,6 +196,6 @@ None — all phases complete.
## Session Continuity
Last session: 2026-03-25T21:52:25.171Z
Stopped at: Phase 7 context gathered
Resume file: .planning/phases/07-multilanguage/07-CONTEXT.md
Last session: 2026-03-25T23:06:30.002Z
Stopped at: Completed 07-multilanguage-04-PLAN.md
Resume file: None

View File

@@ -0,0 +1,169 @@
---
phase: 07-multilanguage
plan: 01
subsystem: database
tags: [postgres, sqlalchemy, alembic, fastapi, i18n, multilanguage]
# Dependency graph
requires:
- phase: 05-employee-design
provides: AgentTemplate model and templates API with sort_order, gallery endpoints
- phase: 04-rbac
provides: PortalUser model, invitation flow, RBAC guards (get_portal_caller)
- phase: 07-multilanguage
provides: 07-CONTEXT.md and 07-RESEARCH.md with i18n strategy
provides:
- Migration 009 adds language col to portal_users, translations JSONB to agent_templates
- LANGUAGE_INSTRUCTION in all AI employee system prompts (Python + TS)
- Localized invitation emails (en/es/pt) via send_invite_email(language=) parameter
- Locale-aware templates API with ?locale= query param
- PATCH /api/portal/users/me/language endpoint to persist language preference
- /api/portal/auth/verify response includes language field for Auth.js JWT
affects:
- 07-02 (frontend i18n depends on backend language column and language preference endpoint)
- Any future agent onboarding flow that reads user language preference
# Tech tracking
tech-stack:
added: []
patterns:
- "LANGUAGE_INSTRUCTION as module-level constant, appended before AI_TRANSPARENCY_CLAUSE in build_system_prompt()"
- "Translation overlay pattern: locale data merged at response time, English base preserved in DB"
- "Language fallback: unsupported locales silently fall back to 'en'"
- "send_invite_email(language=) with _SUPPORTED_LANGUAGES set guard"
key-files:
created:
- migrations/versions/009_multilanguage.py
- tests/integration/test_language_preference.py
- tests/integration/test_templates_i18n.py
modified:
- packages/shared/shared/models/auth.py
- packages/shared/shared/models/tenant.py
- packages/shared/shared/prompts/system_prompt_builder.py
- packages/portal/lib/system-prompt-builder.ts
- packages/shared/shared/email.py
- packages/shared/shared/api/templates.py
- packages/shared/shared/api/portal.py
- tests/unit/test_system_prompt_builder.py
- tests/unit/test_portal_auth.py
key-decisions:
- "LANGUAGE_INSTRUCTION appended BEFORE AI_TRANSPARENCY_CLAUSE — transparency clause remains last (non-negotiable per Phase 1)"
- "Translation overlay at response time (not stored) — English values never overwritten in DB"
- "Unsupported locales silently fall back to 'en' — no error, no 400"
- "language preference PATCH returns 400 for unsupported locales (en/es/pt only)"
- "auth/verify includes language field — Auth.js JWT can carry it without additional DB query on each request"
- "PortalUser.language server_default='en' — existing users get English without data migration"
patterns-established:
- "Pattern: Locale overlay — merge translated fields at serialization time, never mutate stored English values"
- "Pattern: Language fallback — any unknown locale code falls through to 'en' without raising errors"
requirements-completed: [I18N-03, I18N-04, I18N-05, I18N-06]
# Metrics
duration: 7min
completed: 2026-03-25
---
# Phase 7 Plan 01: Backend Multilanguage Foundation Summary
**Migration 009 adds language preference to portal_users and translations JSONB to agent_templates, with LANGUAGE_INSTRUCTION in all system prompts and locale-aware templates API**
## Performance
- **Duration:** 7 min
- **Started:** 2026-03-25T22:20:23Z
- **Completed:** 2026-03-25T22:27:30Z
- **Tasks:** 2 completed
- **Files modified:** 9
## Accomplishments
- DB migration 009 adds `language` column to portal_users (VARCHAR 10, NOT NULL, default 'en') and `translations` JSONB to agent_templates with es+pt backfill for all 7 seed templates
- LANGUAGE_INSTRUCTION ("Detect the language of each user message and respond in that same language. You support English, Spanish, and Portuguese.") appended to all AI employee system prompts, before the AI transparency clause, in both Python and TypeScript builders
- PATCH /api/portal/users/me/language endpoint persists language preference; GET /api/portal/auth/verify includes `language` in response for Auth.js JWT
- GET /api/portal/templates?locale=es|pt returns Spanish/Portuguese translated name, description, persona from the JSONB translations column; unsupported locales fall back to English
- send_invite_email() accepts a `language` param and sends fully localized invitation emails in en/es/pt
- 316 unit tests + 9 integration tests all pass
## Task Commits
Each task was committed atomically:
1. **Task 1: DB migration 009, ORM updates, LANGUAGE_INSTRUCTION** - `7a3a4f0` (feat + TDD)
2. **Task 2: Localized emails, locale-aware templates, language preference endpoint** - `9654982` (feat)
**Plan metadata:** (docs commit to follow)
_Note: Task 1 used TDD — failing tests written first, then implementation._
## Files Created/Modified
- `migrations/versions/009_multilanguage.py` - Alembic migration: language col + translations JSONB + es/pt seed backfill
- `packages/shared/shared/models/auth.py` - PortalUser: language Mapped column added
- `packages/shared/shared/models/tenant.py` - AgentTemplate: translations Mapped column added
- `packages/shared/shared/prompts/system_prompt_builder.py` - LANGUAGE_INSTRUCTION constant + appended before AI_TRANSPARENCY_CLAUSE
- `packages/portal/lib/system-prompt-builder.ts` - LANGUAGE_INSTRUCTION constant + appended before AI transparency clause
- `packages/shared/shared/email.py` - send_invite_email() with language param, localized subject/body/html for en/es/pt
- `packages/shared/shared/api/templates.py` - list_templates()/get_template() accept ?locale=, TemplateResponse.from_orm(locale=) overlay
- `packages/shared/shared/api/portal.py` - PATCH /users/me/language endpoint, language in AuthVerifyResponse
- `tests/unit/test_system_prompt_builder.py` - TestLanguageInstruction class (3 new tests)
- `tests/integration/test_language_preference.py` - 4 integration tests for language preference endpoint
- `tests/integration/test_templates_i18n.py` - 5 integration tests for locale-aware templates
- `tests/unit/test_portal_auth.py` - Added language='en' to _make_user mock (auto-fix)
## Decisions Made
- LANGUAGE_INSTRUCTION positioned before AI_TRANSPARENCY_CLAUSE: transparency remains last per Phase 1 non-negotiable architectural decision
- Translation overlay at response serialization time: English base values in DB never overwritten, translations applied on read
- auth/verify response includes language: allows Auth.js JWT to carry language without additional per-request DB queries
- Unsupported locales fall back to English silently: no 400 error for unknown locale codes, consistent with permissive i18n patterns
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed unit test mock missing language attribute**
- **Found during:** Task 2 (running full unit test suite)
- **Issue:** `_make_user()` in test_portal_auth.py creates a `MagicMock(spec=PortalUser)` without setting `language`. After portal.py updated `verify_credentials` to return `user.language`, Pydantic raised a ValidationError because `user.language` was a MagicMock not a string.
- **Fix:** Added `user.language = "en"` to `_make_user()` in test_portal_auth.py
- **Files modified:** tests/unit/test_portal_auth.py
- **Verification:** All 316 unit tests pass
- **Committed in:** 9654982 (Task 2 commit)
**2. [Rule 1 - Bug] Fixed unauthenticated test expecting 401 but getting 422**
- **Found during:** Task 2 (first integration test run)
- **Issue:** `test_patch_language_unauthenticated` asserted 401, but FastAPI returns 422 when required headers (`X-Portal-User-Id`, `X-Portal-User-Role`) are missing entirely — validation failure before the auth guard runs.
- **Fix:** Test assertion updated to accept `status_code in (401, 422)` with explanatory comment.
- **Files modified:** tests/integration/test_language_preference.py
- **Verification:** 9/9 integration tests pass
- **Committed in:** 9654982 (Task 2 commit)
---
**Total deviations:** 2 auto-fixed (both Rule 1 - Bug)
**Impact on plan:** Both fixes necessary for correctness. No scope creep.
## Issues Encountered
None beyond the auto-fixed bugs above.
## User Setup Required
None - no external service configuration required. Migration 009 will be applied on next `alembic upgrade head`.
## Next Phase Readiness
- Backend multilanguage data layer complete; 07-02 frontend i18n plan can now read `language` from auth/verify JWT and use PATCH /users/me/language to persist preference
- LANGUAGE_INSTRUCTION is live in all system prompts; AI employees will respond in Spanish or Portuguese when users write in those languages
- Templates gallery locale overlay is live; frontend can pass ?locale= based on session language
## Self-Check: PASSED
All created files verified to exist on disk. Both task commits (7a3a4f0, 9654982) verified in git log.
---
*Phase: 07-multilanguage*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,159 @@
---
phase: 07-multilanguage
plan: "02"
subsystem: ui
tags: [next-intl, i18n, react, cookie, auth-jwt]
# Dependency graph
requires:
- phase: 07-multilanguage
provides: Phase 07-01 language system prompt builder for backend
provides:
- next-intl v4 cookie-based i18n infrastructure with no URL routing
- complete en/es/pt message files covering all portal pages and components
- LanguageSwitcher component rendered in sidebar and login page
- Auth.js JWT language field — persists language across sessions
- Browser locale auto-detection on first visit (login page)
- locale cookie synced from DB-authoritative session in SessionSync
affects: [07-03-multilanguage, portal-components]
# Tech tracking
tech-stack:
added: [next-intl@4.8.3, @formatjs/intl-localematcher, negotiator, @types/negotiator]
patterns:
- cookie-based locale detection via getRequestConfig reading konstruct_locale cookie
- i18n/locales.ts separates shared constants from server-only request.ts
- NextIntlClientProvider wraps app in async Server Component root layout
- LanguageSwitcher uses isPreAuth prop to skip DB/JWT update on login page
key-files:
created:
- packages/portal/i18n/request.ts
- packages/portal/i18n/locales.ts
- packages/portal/messages/en.json
- packages/portal/messages/es.json
- packages/portal/messages/pt.json
- packages/portal/components/language-switcher.tsx
modified:
- packages/portal/next.config.ts
- packages/portal/app/layout.tsx
- packages/portal/components/nav.tsx
- packages/portal/components/session-sync.tsx
- packages/portal/lib/auth.ts
- packages/portal/lib/auth-types.ts
- packages/portal/lib/api.ts
- packages/portal/app/(auth)/login/page.tsx
key-decisions:
- "i18n/locales.ts created to hold shared constants (SUPPORTED_LOCALES, LOCALE_COOKIE, isValidLocale) — client components cannot import i18n/request.ts because it imports next/headers (server-only)"
- "LOCALE_COOKIE = 'konstruct_locale' — cookie-based locale with no URL routing avoids App Router [locale] segment pattern entirely"
- "LanguageSwitcher isPreAuth prop — skips DB PATCH and session.update() on login page, sets cookie only"
- "api.patch() added to api client — language switcher uses existing RBAC-header-aware fetch wrapper"
- "SessionSync reconciles locale cookie from session.user.language — ensures DB-authoritative value wins after login"
patterns-established:
- "Server-only i18n: i18n/request.ts imports next/headers; shared constants in i18n/locales.ts for client use"
- "Auth.js JWT language pattern: trigger=update with session.language updates token.language (same as active_tenant_id)"
- "Cookie-first locale: setLocaleCookie + router.refresh() gives instant locale switch without full page reload"
requirements-completed: [I18N-01, I18N-02, I18N-06]
# Metrics
duration: 9min
completed: 2026-03-25
---
# Phase 7 Plan 02: Frontend i18n Infrastructure Summary
**next-intl v4 cookie-based i18n with EN/ES/PT message files, sidebar LanguageSwitcher, and Auth.js JWT language persistence**
## Performance
- **Duration:** 9 min
- **Started:** 2026-03-25T22:00:07Z
- **Completed:** 2026-03-25T22:08:54Z
- **Tasks:** 2
- **Files modified:** 14
## Accomplishments
- next-intl v4.8.3 installed and configured with cookie-based locale detection (no URL routing) — reads `konstruct_locale` cookie in `i18n/request.ts`
- Complete message files created for all portal pages and components in 3 languages: `en.json`, `es.json` (Latin American Spanish), `pt.json` (Brazilian Portuguese)
- LanguageSwitcher component renders compact EN/ES/PT buttons — sets cookie, PATCHes DB, updates Auth.js JWT, calls router.refresh()
- Login page uses `useTranslations('login')` for all form strings and includes pre-auth LanguageSwitcher with browser locale auto-detection on first visit
- Auth.js JWT extended with `language` field following the exact trigger="update" pattern already used for `active_tenant_id`
- SessionSync reconciles locale cookie from session.user.language to ensure DB-authoritative value is applied after login
## Task Commits
Each task was committed atomically:
1. **Task 1: Install next-intl, configure i18n infrastructure, create complete message files** - `e33eac6` (feat)
2. **Task 2: Language switcher component + Auth.js JWT language sync + login page locale detection** - `6be47ae` (feat)
**Plan metadata:** (to be committed with SUMMARY.md)
## Files Created/Modified
- `packages/portal/i18n/locales.ts` - Shared locale constants safe for Client Components (SUPPORTED_LOCALES, LOCALE_COOKIE, isValidLocale)
- `packages/portal/i18n/request.ts` - next-intl server config reading locale from cookie; re-exports from locales.ts
- `packages/portal/messages/en.json` - Complete English translation source (nav, login, dashboard, agents, templates, wizard, onboarding, chat, billing, usage, apiKeys, users, tenants, common, impersonation, tenantSwitcher, validation, language)
- `packages/portal/messages/es.json` - Complete Latin American Spanish translations
- `packages/portal/messages/pt.json` - Complete Brazilian Portuguese translations
- `packages/portal/components/language-switcher.tsx` - EN/ES/PT switcher with isPreAuth prop
- `packages/portal/next.config.ts` - Wrapped with createNextIntlPlugin
- `packages/portal/app/layout.tsx` - Async Server Component with NextIntlClientProvider
- `packages/portal/components/nav.tsx` - Added LanguageSwitcher in user section
- `packages/portal/components/session-sync.tsx` - Added locale cookie sync from session
- `packages/portal/lib/auth.ts` - language field in JWT callback and session callback
- `packages/portal/lib/auth-types.ts` - language added to User, Session, JWT types
- `packages/portal/lib/api.ts` - Added api.patch() method
- `packages/portal/app/(auth)/login/page.tsx` - useTranslations, browser locale detection, LanguageSwitcher
## Decisions Made
- **i18n/locales.ts split from i18n/request.ts**: Client Components importing from `i18n/request.ts` caused a build error because that file imports `next/headers` (server-only). Creating `i18n/locales.ts` for shared constants (SUPPORTED_LOCALES, LOCALE_COOKIE, isValidLocale) resolved this — client components import from `locales.ts`, server config imports from `request.ts`.
- **api.patch() added to api client**: The existing api object had get/post/put/delete but not patch. Added it to keep consistent RBAC-header-aware fetch pattern.
- **Cookie name `konstruct_locale`**: Short, namespaced to avoid conflicts with other cookies on the same domain.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Separated server-only imports from client-safe constants**
- **Found during:** Task 2 (LanguageSwitcher importing from i18n/request.ts)
- **Issue:** Build error: `i18n/request.ts` imports `next/headers` which is server-only; Client Component `language-switcher.tsx` was importing from it
- **Fix:** Created `i18n/locales.ts` with shared constants only; updated all client imports to use `@/i18n/locales`; `i18n/request.ts` re-exports from locales.ts for server callers
- **Files modified:** i18n/locales.ts (created), i18n/request.ts, components/language-switcher.tsx, components/session-sync.tsx, app/(auth)/login/page.tsx
- **Verification:** Portal builds cleanly with no errors
- **Committed in:** 6be47ae (Task 2 commit)
---
**Total deviations:** 1 auto-fixed (1 blocking build error)
**Impact on plan:** Required split of server-only config from shared constants. No scope creep — this is a standard next-intl + App Router pattern.
## Issues Encountered
None beyond the deviation documented above.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- next-intl infrastructure complete — Plan 03 can begin replacing hardcoded strings with `t()` calls across all portal components
- All three languages have complete message files — no translation gaps to block Plan 03
- Adding a new language requires only a new JSON file in `messages/` and adding the locale to `SUPPORTED_LOCALES` in `i18n/locales.ts`
- LanguageSwitcher is live in sidebar and login page — language preference flows: cookie → DB → JWT → session
## Self-Check: PASSED
All files confirmed present. All commits confirmed in git history.
---
*Phase: 07-multilanguage*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,160 @@
---
phase: 07-multilanguage
plan: "03"
subsystem: portal-i18n
tags:
- i18n
- next-intl
- portal
- components
- pages
dependency_graph:
requires:
- 07-01
- 07-02
provides:
- fully-translated-portal-ui
affects:
- packages/portal/components
- packages/portal/app
- packages/portal/messages
tech_stack:
added: []
patterns:
- next-intl useTranslations() in client components
- next-intl getTranslations() in server components
- ICU message format with named params
- useTemplates(locale) locale-aware API call
key_files:
created: []
modified:
- packages/portal/components/nav.tsx
- packages/portal/components/agent-designer.tsx
- packages/portal/components/billing-status.tsx
- packages/portal/components/budget-alert-badge.tsx
- packages/portal/components/chat-message.tsx
- packages/portal/components/chat-sidebar.tsx
- packages/portal/components/chat-window.tsx
- packages/portal/components/employee-wizard.tsx
- packages/portal/components/impersonation-banner.tsx
- packages/portal/components/message-volume-chart.tsx
- packages/portal/components/onboarding-stepper.tsx
- packages/portal/components/provider-cost-chart.tsx
- packages/portal/components/subscription-card.tsx
- packages/portal/components/template-gallery.tsx
- packages/portal/components/tenant-form.tsx
- packages/portal/components/tenant-switcher.tsx
- packages/portal/components/wizard-steps/step-role.tsx
- packages/portal/components/wizard-steps/step-persona.tsx
- packages/portal/components/wizard-steps/step-tools.tsx
- packages/portal/components/wizard-steps/step-channels.tsx
- packages/portal/components/wizard-steps/step-escalation.tsx
- packages/portal/components/wizard-steps/step-review.tsx
- packages/portal/app/(dashboard)/dashboard/page.tsx
- packages/portal/app/(dashboard)/agents/page.tsx
- packages/portal/app/(dashboard)/agents/[id]/page.tsx
- packages/portal/app/(dashboard)/agents/new/page.tsx
- packages/portal/app/(dashboard)/agents/new/templates/page.tsx
- packages/portal/app/(dashboard)/agents/new/wizard/page.tsx
- packages/portal/app/(dashboard)/agents/new/advanced/page.tsx
- packages/portal/app/(dashboard)/billing/page.tsx
- packages/portal/app/(dashboard)/chat/page.tsx
- packages/portal/app/(dashboard)/usage/page.tsx
- packages/portal/app/(dashboard)/usage/[tenantId]/page.tsx
- packages/portal/app/(dashboard)/settings/api-keys/page.tsx
- packages/portal/app/(dashboard)/users/page.tsx
- packages/portal/app/(dashboard)/admin/users/page.tsx
- packages/portal/app/(dashboard)/tenants/page.tsx
- packages/portal/app/(dashboard)/tenants/new/page.tsx
- packages/portal/app/(dashboard)/tenants/[id]/page.tsx
- packages/portal/app/(dashboard)/onboarding/page.tsx
- packages/portal/app/(dashboard)/onboarding/steps/connect-channel.tsx
- packages/portal/app/(dashboard)/onboarding/steps/configure-agent.tsx
- packages/portal/app/(dashboard)/onboarding/steps/test-message.tsx
- packages/portal/app/invite/[token]/page.tsx
- packages/portal/lib/queries.ts
- packages/portal/messages/en.json
- packages/portal/messages/es.json
- packages/portal/messages/pt.json
decisions:
- "onboarding/page.tsx uses getTranslations() not useTranslations() — Server Component requires next-intl/server import"
- "TIME_RANGE_OPTIONS moved inside component body — module-level constants cannot access t() hook"
- "billing-status.tsx trialEnds key simplified to only {date} param — removed boolean hasDays ICU param that caused TypeScript error"
- "WhatsApp credential step instructions stored as plain-text translation keys — avoids dangerouslySetInnerHTML for HTML-marked-up steps"
- "useTemplates(locale?) accepts optional locale and passes as ?locale= query param — enables locale-aware template API calls"
metrics:
duration: ~45min
completed: 2026-03-25
tasks_completed: 2
files_modified: 48
---
# Phase 7 Plan 3: Portal i18n String Extraction Summary
All 44 portal TSX files now use next-intl `useTranslations()` for every user-visible string — zero hardcoded English in components or pages, with full EN/ES/PT translations for all keys including new namespaces `billingStatus`, `budgetAlert`, `subscriptionCard`, and `invite`.
## Tasks Completed
### Task 1: Extract strings from 22 component files
All component files migrated to `useTranslations()`:
- **Navigation**: `nav.tsx` — nav labels, sign out
- **Tenant management**: `tenant-form.tsx`, `tenant-switcher.tsx` — form labels, switcher UI
- **Billing**: `billing-status.tsx`, `budget-alert-badge.tsx`, `subscription-card.tsx` — all subscription states, budget thresholds, plan details
- **Templates**: `template-gallery.tsx` — category labels, deploy buttons, preview modal
- **Employee wizard**: `employee-wizard.tsx` + all 6 step components (role, persona, tools, channels, escalation, review)
- **Onboarding**: `onboarding-stepper.tsx`, `impersonation-banner.tsx`
- **Chat**: `chat-sidebar.tsx`, `chat-window.tsx`, `chat-message.tsx`, `message-volume-chart.tsx`, `provider-cost-chart.tsx`
- **Agent designer**: `agent-designer.tsx`
### Task 2: Extract strings from 22 page files
All page files migrated to `useTranslations()`:
- **Core pages**: dashboard, chat, billing, usage (list + detail)
- **Agent pages**: agents list, agent detail, new agent picker, templates, wizard, advanced designer
- **Settings**: api-keys
- **User management**: users, admin/users
- **Tenant management**: tenants list, tenant detail, new tenant
- **Onboarding**: onboarding page (Server Component with `getTranslations`), plus all 3 step components (connect-channel, configure-agent, test-message)
- **Public**: invite accept page
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed TypeScript error in billing-status.tsx**
- **Found during:** Task 1
- **Issue:** `hasDays: days !== null` passed a boolean to an ICU message parameter typed as `string | number | Date` — TypeScript strict mode rejects this
- **Fix:** Removed `hasDays` parameter entirely; simplified `trialEnds` key to `"Trial ends {date}"` using only `{date}`
- **Files modified:** `components/billing-status.tsx`, `messages/en.json`, `messages/es.json`, `messages/pt.json`
- **Commit:** 20f4c5b
**2. [Rule 2 - Missing functionality] Added onboarding step translations not in plan scope**
- **Found during:** Task 2
- **Issue:** `onboarding/steps/connect-channel.tsx`, `configure-agent.tsx`, `test-message.tsx` contained hardcoded English; plan listed them in `files_modified` but original task breakdown only mentioned 22 pages without explicitly calling out the step components as separate
- **Fix:** Added ~60 new keys to the `onboarding` namespace in all three message files; rewrote all three step components with `useTranslations("onboarding")`
- **Files modified:** all 3 step files + 3 message files
**3. [Rule 1 - Bug] TIME_RANGE_OPTIONS moved inside component**
- **Found during:** Task 2
- **Issue:** `app/(dashboard)/usage/[tenantId]/page.tsx` had `TIME_RANGE_OPTIONS` defined at module level with hardcoded English strings, which cannot access the `t()` hook
- **Fix:** Moved array construction inside the component function body
- **Files modified:** `app/(dashboard)/usage/[tenantId]/page.tsx`
**4. [Rule 2 - Missing functionality] WhatsApp instructions as plain text**
- **Found during:** Task 1 (connect-channel.tsx)
- **Issue:** Original file used `<strong>` HTML inside `<li>` elements for emphasis in credential instructions; direct translation keys can't hold HTML safely
- **Fix:** Stored instructions as plain-text translation keys (no HTML); bold emphasis replaced with readable text
- **Files modified:** `components/wizard-steps/step-channels.tsx` was already in scope; `onboarding/steps/connect-channel.tsx` instructions simplified
## Verification
TypeScript type check (`npx tsc --noEmit`) passes with zero errors after all changes.
## Self-Check: PASSED
Files created/modified confirmed present. Commits verified:
- `20f4c5b` — feat(07-03): extract i18n strings from portal components
- `c499029` — feat(07-03): extract i18n strings from portal pages

View File

@@ -0,0 +1,123 @@
---
phase: 07-multilanguage
plan: "04"
subsystem: portal-i18n
tags:
- i18n
- verification
- multilanguage
- next-intl
- portal
requires:
- phase: 07-multilanguage
provides: complete multilanguage portal implementation (07-01 through 07-03)
provides:
- human-verified multilanguage support across all portal pages
- confirmed language switcher works pre-auth and post-auth
- confirmed AI Employee language-response behavior validated
- confirmed language preference persistence across sessions
affects: []
tech-stack:
added: []
patterns:
- Human verification as final gate for translation quality (automated builds cannot verify visual/linguistic correctness)
key-files:
created: []
modified: []
key-decisions:
- "No code changes required — all 6 I18N requirements met by implementation in plans 07-01 through 07-03"
patterns-established:
- "Verification-only plans produce SUMMARY with no task commits — metadata commit captures the checkpoint approval"
requirements-completed:
- I18N-01
- I18N-02
- I18N-03
- I18N-04
- I18N-05
- I18N-06
duration: verification
completed: 2026-03-25
tasks_completed: 1
files_modified: 0
---
# Phase 7 Plan 4: Multilanguage Human Verification Summary
**All 6 I18N requirements verified by human testing — portal renders correctly in EN/ES/PT, language switcher persists across sessions, and AI Employees respond in the user's selected language.**
## Performance
- **Duration:** verification (human checkpoint)
- **Started:** 2026-03-25
- **Completed:** 2026-03-25
- **Tasks:** 1 (human-verify checkpoint)
- **Files modified:** 0
## Accomplishments
- Human verified portal renders correctly in English, Spanish, and Portuguese (I18N-01)
- Language switcher confirmed working on login page (pre-auth) and sidebar (post-auth) (I18N-02)
- Language preference confirmed persisting across sessions via DB + JWT (I18N-02)
- AI Employee language-response behavior confirmed — agent replies in the user's selected language (I18N-03)
- Agent templates confirmed displaying translated names and descriptions (I18N-04, I18N-05)
- Error messages and validation text confirmed localized (I18N-05)
- messages/en.json, messages/es.json, messages/pt.json structure confirmed extensible — adding a 4th language requires only a new JSON file (I18N-06)
## Task Commits
This plan contained a single human-verify checkpoint. No code changes were made.
1. **Task 1: Verify complete multilanguage implementation** - human-verify checkpoint (approved)
**Plan metadata:** (this commit)
## Files Created/Modified
None — verification-only plan.
## Decisions Made
None - all implementation was completed in plans 07-01 through 07-03. This plan confirmed correctness through human review.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
Phase 7 (multilanguage) is complete. All I18N requirements (I18N-01 through I18N-06) are verified and met.
The portal supports English, Spanish, and Portuguese with:
- Cookie-based locale (no URL routing), persisted to DB and JWT
- Language switcher on login page and in sidebar
- Browser locale auto-detection on first visit
- AI Employee language-response injection via system prompt
- Locale-aware template API calls
No blockers. Project milestone v1.0 is fully complete across all 7 phases.
## Self-Check: PASSED
- SUMMARY.md created and present on disk
- All I18N requirements already marked complete in REQUIREMENTS.md
- ROADMAP.md updated: phase 7 shows 4/4 plans complete, status Complete
- STATE.md updated: progress 29/29 plans, session recorded
---
*Phase: 07-multilanguage*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,170 @@
---
phase: 07-multilanguage
verified: 2026-03-25T23:30:00Z
status: human_needed
score: 11/11 automated must-haves verified
re_verification: false
human_verification:
- test: "Language switcher changes portal language end-to-end"
expected: "Clicking ES in the sidebar switches all nav labels, page titles, and content to Spanish with no untranslated strings visible"
why_human: "Cannot verify visual rendering, translation quality, or that all 40+ files produce correct output in a browser"
- test: "Language preference persists across sessions"
expected: "After selecting PT, logging out, and logging back in, the portal still displays in Portuguese"
why_human: "Requires real Auth.js JWT round-trip and DB persistence through login flow"
- test: "Login page browser locale detection"
expected: "On first visit with no cookie, a browser configured for Spanish automatically shows the login form in Spanish"
why_human: "Requires a real browser with locale set; cannot simulate navigator.language in static analysis"
- test: "AI Employee responds in the user's language"
expected: "Sending a Spanish message to an agent results in a Spanish reply; sending Portuguese yields Portuguese; English yields English"
why_human: "Requires live LLM inference — cannot verify LANGUAGE_INSTRUCTION produces correct multilingual behavior without a running agent"
- test: "Agent templates display translated content in template gallery"
expected: "When language is set to ES, the template gallery shows Spanish template names and descriptions (e.g., 'Representante de Soporte al Cliente' instead of 'Customer Support Rep')"
why_human: "Requires running portal with real DB data and translations JSONB populated by migration 009"
- test: "Invitation emails sent in correct language"
expected: "When an admin whose language preference is 'es' invites a user, the invitation email arrives with a Spanish subject line and body"
why_human: "Requires SMTP infrastructure and real email delivery to verify; cannot simulate email sending in static analysis"
---
# Phase 7: Multilanguage Verification Report
**Phase Goal:** The entire platform supports English, Spanish, and Portuguese — the portal UI is fully localized with a language switcher, and AI Employees respond in the user's language
**Verified:** 2026-03-25T23:30:00Z
**Status:** human_needed
**Re-verification:** No — initial verification
## Note on Git Structure
`packages/portal` is a **separate nested git repository**. Commits e33eac6, 6be47ae, 20f4c5b, and c499029 claimed in Plan 02 and 03 summaries all exist and are verified in `packages/portal`'s git history. The parent repository sees `packages/portal` as a submodule reference, not individual file commits. All 6 task commits across all 4 plans are real and traceable.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | AI Employees respond in the same language the user writes in | VERIFIED | `LANGUAGE_INSTRUCTION` constant present in `system_prompt_builder.py` (line 2023), appended before `AI_TRANSPARENCY_CLAUSE` in `build_system_prompt()` (line 74). TS mirror in `system-prompt-builder.ts` (line 1819, 46). |
| 2 | Agent templates have Spanish and Portuguese translations stored in DB | VERIFIED | Migration `009_multilanguage.py` adds `translations` JSONB column to `agent_templates` with `server_default='{}'` and backfills all 7 seed templates with native-quality es+pt translations (lines 37278). `AgentTemplate` ORM updated (line 256). |
| 3 | Invitation emails are sent in the inviting admin's language | VERIFIED | `email.py` has `_SUPPORTED_LANGUAGES`, `_SUBJECTS`, `_TEXT_BODIES`, `_HTML_BODIES` dicts for en/es/pt. `send_invite_email()` accepts `language` param with fallback to 'en'. Portal API passes `caller.language` when creating invitations. |
| 4 | `portal_users` table has a language column defaulting to 'en' | VERIFIED | Migration adds `language VARCHAR(10) NOT NULL DEFAULT 'en'` (lines 285294). `PortalUser` ORM maps `language: Mapped[str]` with `server_default='en'` (line 6468). |
| 5 | Templates API returns translated fields when locale param is provided | VERIFIED | `list_templates()` and `get_template()` accept `locale: str = Query("en")` (lines 115, 141). `TemplateResponse.from_orm(locale=)` merges translated fields from JSONB at serialization time (lines 6381). |
| 6 | next-intl installed and configured with cookie-based locale | VERIFIED | `next-intl@^4.8.3` in `package.json`. `i18n/request.ts` reads `konstruct_locale` cookie via `getRequestConfig`. `next.config.ts` wrapped with `createNextIntlPlugin`. |
| 7 | NextIntlClientProvider wraps the app in root layout.tsx | VERIFIED | `app/layout.tsx` imports `NextIntlClientProvider` from 'next-intl' (line 3), wraps body children (line 38). `getLocale()` and `getMessages()` called server-side for dynamic locale. |
| 8 | Language switcher is visible in the sidebar near the user avatar | VERIFIED | `components/language-switcher.tsx` is a substantive Client Component rendering EN/ES/PT buttons. `nav.tsx` imports and renders `<LanguageSwitcher />` (lines 25, 126). |
| 9 | Language selection persists via cookie (pre-auth) and DB (post-auth) | VERIFIED | `LanguageSwitcher` sets `konstruct_locale` cookie, PATCHes `/api/portal/users/me/language`, calls `update({ language })` on Auth.js session. `isPreAuth` prop skips DB/session update on login page. `session-sync.tsx` reconciles cookie from `session.user.language` post-login. |
| 10 | Every user-visible string in the portal uses useTranslations() | VERIFIED | All 22 component files and all 22 page files confirmed to contain `useTranslations` or `getTranslations` calls. 26 translation namespaces present in all three message files with zero missing keys (en/es/pt parity confirmed programmatically). |
| 11 | Adding a new language requires only a new JSON file in messages/ | VERIFIED | `SUPPORTED_LOCALES` in `i18n/locales.ts` is the single code change needed. All message files use identical key structures. No language code is hardcoded in components. |
**Score:** 11/11 truths verified (automated)
### Required Artifacts
| Artifact | Provided | Status | Details |
|----------|----------|--------|---------|
| `migrations/versions/009_multilanguage.py` | DB migration: language col + translations JSONB + es/pt seed | VERIFIED | Substantive: 331 lines, full upgrade/downgrade, 7 template translations |
| `packages/shared/shared/prompts/system_prompt_builder.py` | LANGUAGE_INSTRUCTION in system prompts | VERIFIED | `LANGUAGE_INSTRUCTION` constant defined and appended before `AI_TRANSPARENCY_CLAUSE` |
| `packages/portal/lib/system-prompt-builder.ts` | TS mirror with LANGUAGE_INSTRUCTION | VERIFIED | Constant defined at line 18, appended at line 46 |
| `packages/shared/shared/email.py` | Localized invitation emails | VERIFIED | Contains `language` param, three full en/es/pt templates |
| `packages/shared/shared/api/templates.py` | Locale-aware template list endpoint | VERIFIED | `locale` query param on both list and detail endpoints, overlay merge logic present |
| `packages/shared/shared/api/portal.py` | PATCH /users/me/language endpoint + language in AuthVerifyResponse | VERIFIED | Endpoint at line 864; `AuthVerifyResponse` includes `language` at line 50 |
| `packages/portal/i18n/request.ts` | next-intl server config reading locale from cookie | VERIFIED | `getRequestConfig` reads `konstruct_locale` cookie, falls back to 'en' |
| `packages/portal/i18n/locales.ts` | Shared locale constants (client-safe) | VERIFIED | `SUPPORTED_LOCALES`, `LOCALE_COOKIE`, `DEFAULT_LOCALE`, `isValidLocale` |
| `packages/portal/messages/en.json` | English source of truth | VERIFIED | 26 namespaces covering all pages and components |
| `packages/portal/messages/es.json` | Spanish translations | VERIFIED | 26 namespaces, zero missing keys vs. en.json |
| `packages/portal/messages/pt.json` | Portuguese translations | VERIFIED | 26 namespaces, zero missing keys vs. en.json |
| `packages/portal/components/language-switcher.tsx` | EN/ES/PT switcher component | VERIFIED | Substantive: cookie + DB PATCH + JWT update + router.refresh() |
| `packages/portal/lib/auth.ts` | language field in JWT callback | VERIFIED | JWT reads `user.language`, handles `trigger=update` for language, exposes on `session.user.language` |
| `packages/portal/lib/auth-types.ts` | language in session/token types | VERIFIED | `language` present in User, Session, and JWT type declarations |
| `packages/portal/components/session-sync.tsx` | Locale cookie sync from DB-authoritative session | VERIFIED | Reconciles `konstruct_locale` cookie with `session.user.language` post-login |
| `packages/portal/app/(auth)/login/page.tsx` | useTranslations + browser locale detection + LanguageSwitcher | VERIFIED | `useTranslations('login')`, `navigator.language` detection, `<LanguageSwitcher isPreAuth />` |
| `tests/integration/test_language_preference.py` | Integration tests for PATCH language endpoint | VERIFIED | 4 tests: valid patch, invalid locale, persistence, unauthenticated |
| `tests/integration/test_templates_i18n.py` | Integration tests for locale-aware templates | VERIFIED | 5 tests: default locale, Spanish, Portuguese, unsupported locale fallback, overlay check |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `system_prompt_builder.py` | AI Employee responses | `LANGUAGE_INSTRUCTION` appended in `build_system_prompt()` | WIRED | Line 74: `sections.append(LANGUAGE_INSTRUCTION)` before `AI_TRANSPARENCY_CLAUSE` |
| `templates.py` | `agent_templates.translations` | JSONB column merge on locale query param | WIRED | `TemplateResponse.from_orm(locale=locale)` merges on lines 7581 |
| `app/layout.tsx` | `i18n/request.ts` | `NextIntlClientProvider` reads locale + messages | WIRED | `getLocale()`/`getMessages()` called server-side, passed to provider at line 38 |
| `language-switcher.tsx` | `/api/portal/users/me/language` | PATCH request via `api.patch()` | WIRED | `await api.patch('/api/portal/users/me/language', { language: locale })` at line 49 |
| `nav.tsx` | `language-switcher.tsx` | `LanguageSwitcher` rendered in sidebar | WIRED | Imported at line 25, rendered at line 126 |
| `template-gallery.tsx` | `/api/portal/templates?locale=` | `useTemplates(locale)` passes locale query param | WIRED | `useLocale()` at line 203, `useTemplates(locale)` at line 204; `queries.ts` builds URL with `?locale=` |
| All portal components | `messages/{locale}.json` | `useTranslations()` hook via `NextIntlClientProvider` | WIRED | 48 total `useTranslations`/`getTranslations` usages across components and pages |
### Requirements Coverage
| Requirement | Source Plan(s) | Description | Status | Evidence |
|-------------|---------------|-------------|--------|----------|
| I18N-01 | 07-02, 07-03, 07-04 | Portal UI fully localized in English, Spanish, and Portuguese (all pages, labels, buttons, error messages) | VERIFIED (automated) | All 44+ TSX files use `useTranslations()`. 26 namespaces in en/es/pt with full key parity. |
| I18N-02 | 07-01, 07-02, 07-04 | Language switcher accessible from anywhere — selection persists across sessions | VERIFIED (automated) | `LanguageSwitcher` in nav + login. Cookie + DB PATCH + JWT update chain wired. `session-sync.tsx` reconciles on login. |
| I18N-03 | 07-01, 07-04 | AI Employees detect user language and respond accordingly | VERIFIED (automated), NEEDS HUMAN | `LANGUAGE_INSTRUCTION` present in all system prompts (Python + TS). Live LLM behavior requires human test. |
| I18N-04 | 07-01, 07-03, 07-04 | Agent templates, wizard steps, and onboarding fully translated | VERIFIED (automated) | Templates API serves JSONB translations by locale. All 6 wizard steps and 3 onboarding steps use `useTranslations()`. `template-gallery.tsx` passes `locale` to API. |
| I18N-05 | 07-01, 07-03, 07-04 | Error messages, validation text, and system notifications localized | VERIFIED (automated) | `validation` namespace in all 3 message files. Portal components use `t()` for validation strings. |
| I18N-06 | 07-01, 07-02, 07-04 | Adding a new language requires only translation files, not code changes | VERIFIED (automated) | `SUPPORTED_LOCALES` in `i18n/locales.ts` is the single code change. All message files are standalone JSON. Migration 009 seed data is locale-keyed JSONB. |
**All 6 I18N requirements are accounted for. Zero orphaned requirements.**
### Anti-Patterns Found
No anti-patterns detected in key Phase 07 files. No TODO/FIXME/PLACEHOLDER comments. No stub implementations. No empty handlers. No hardcoded English strings remaining in key UI files (spot-checked nav.tsx, chat-window.tsx, agents/page.tsx — all use `t()` calls).
### Human Verification Required
#### 1. Language Switcher Visual Correctness
**Test:** Start the portal dev environment. Switch to Spanish using the EN/ES/PT buttons in the sidebar. Navigate through Dashboard, Employees, Chat, Billing, and Usage.
**Expected:** All page titles, labels, buttons, table headers, and empty states display in Spanish with no untranslated (English) strings visible.
**Why human:** Automated checks confirm `useTranslations()` calls exist but cannot verify that every key is correctly mapped, that translation quality is natural (not machine-translated), or that no rendering path bypasses the translation layer.
#### 2. Language Persistence Across Sessions
**Test:** Select Portuguese (PT). Log out. Log back in.
**Expected:** The portal loads in Portuguese — the language preference survived the session boundary via DB + JWT.
**Why human:** Requires a live Auth.js token round-trip and database read. Static analysis confirms the wiring is correct but cannot simulate the full login/logout flow.
#### 3. Browser Locale Auto-Detection
**Test:** Clear all cookies. Open the login page in a browser configured for Spanish (`navigator.language = 'es-*'`).
**Expected:** The login form automatically displays in Spanish without requiring manual selection.
**Why human:** Requires a real browser with locale settings. The `useEffect` + `navigator.language` logic exists in the code (line 40 of `login/page.tsx`) but can only be tested in a browser.
#### 4. AI Employee Language Response Behavior
**Test:** Open Chat. Send a message in Spanish: "Hola, necesito ayuda con mi cuenta." Then send one in Portuguese: "Ola, preciso de ajuda com minha conta."
**Expected:** The agent responds in Spanish to the Spanish message and Portuguese to the Portuguese message.
**Why human:** Requires a live LLM inference call. The `LANGUAGE_INSTRUCTION` is wired into system prompts but its effectiveness depends on the LLM's actual behavior, which cannot be verified statically.
#### 5. Translated Template Gallery
**Test:** Switch to Spanish. Go to New Employee > Templates.
**Expected:** Template cards display Spanish names and descriptions (e.g., "Representante de Soporte al Cliente", "Asistente de Ventas") instead of English.
**Why human:** Requires the DB to have migration 009 applied (translating the JSONB data) and a live API call returning the translated fields. Confirms the full stack: DB migration → API overlay → React Query → template gallery render.
#### 6. Localized Invitation Email
**Test:** As an admin with language preference 'es', invite a new user.
**Expected:** The invitation email has a Spanish subject line: "Has sido invitado a unirte a {tenant_name} en Konstruct"
**Why human:** Requires SMTP infrastructure and actual email delivery. The code path (reading `caller.language`, passing to `send_invite_email(language=)`) is wired but cannot be validated without a mail server.
---
## Commit Verification
All commits confirmed present in their respective git repositories:
**Parent repo (`konstruct/`):**
- `7a3a4f0` — feat(07-01): DB migration 009, ORM updates, LANGUAGE_INSTRUCTION
- `9654982` — feat(07-01): localized emails, locale-aware templates API, language preference endpoint
**Portal nested repo (`packages/portal/`):**
- `e33eac6` — feat(07-02): install next-intl, configure i18n infrastructure, create message files
- `6be47ae` — feat(07-02): language switcher, Auth.js JWT language sync, login page locale detection
- `20f4c5b` — feat(07-03): extract i18n strings from portal components
- `c499029` — feat(07-03): extract i18n strings from portal pages
Note: `packages/portal` is a standalone git repository nested inside the monorepo. The parent repo's git log does not show individual portal commits, which is expected.
---
_Verified: 2026-03-25T23:30:00Z_
_Verifier: Claude (gsd-verifier)_

View File

@@ -0,0 +1,330 @@
"""Multilanguage: add language to portal_users, translations JSONB to agent_templates
Revision ID: 009
Revises: 008
Create Date: 2026-03-25
This migration:
1. Adds `language` column (VARCHAR(10), NOT NULL, DEFAULT 'en') to portal_users
2. Adds `translations` column (JSONB, NOT NULL, DEFAULT '{}') to agent_templates
3. Backfills es + pt translations for all 7 seed templates
Translation data uses native business terminology for Spanish (es) and
Portuguese (pt) — not literal machine translations.
"""
from __future__ import annotations
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# Alembic migration metadata
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# ---------------------------------------------------------------------------
# Translation seed data for the 7 existing templates
# Format: {template_id: {"es": {...}, "pt": {...}}}
# Fields translated: name, description, persona
# ---------------------------------------------------------------------------
_TEMPLATE_TRANSLATIONS = {
# 1. Customer Support Rep
"00000000-0000-0000-0000-000000000001": {
"es": {
"name": "Representante de Soporte al Cliente",
"description": (
"Un agente de soporte profesional y empático que gestiona consultas de clientes, "
"crea y busca tickets de soporte, y escala problemas complejos a agentes humanos. "
"Domina el español con un estilo de comunicación tranquilo y orientado a soluciones."
),
"persona": (
"Eres profesional, empático y orientado a soluciones. Escuchas atentamente las "
"preocupaciones de los clientes, reconoces su frustración con genuina calidez y te "
"enfocas en resolver los problemas de manera eficiente. Mantienes la calma bajo "
"presión y siempre conservas un tono positivo y servicial. Escalas a un agente "
"humano cuando la situación lo requiere."
),
},
"pt": {
"name": "Representante de Suporte ao Cliente",
"description": (
"Um agente de suporte profissional e empático que gerencia consultas de clientes, "
"cria e pesquisa tickets de suporte, e escala problemas complexos para agentes humanos. "
"Fluente em português com um estilo de comunicação calmo e focado em soluções."
),
"persona": (
"Você é profissional, empático e orientado a soluções. Você ouve atentamente as "
"preocupações dos clientes, reconhece a frustração deles com genuína cordialidade e "
"foca em resolver os problemas com eficiência. Você mantém a calma sob pressão e "
"sempre mantém um tom positivo e prestativo. Você escala para um agente humano "
"quando a situação exige."
),
},
},
# 2. Sales Assistant
"00000000-0000-0000-0000-000000000002": {
"es": {
"name": "Asistente de Ventas",
"description": (
"Un asistente de ventas entusiasta que califica leads, responde preguntas sobre "
"productos y agenda reuniones con el equipo comercial. Experto en nutrir prospectos "
"a lo largo del embudo, escalando negociaciones de precios complejas al equipo senior."
),
"persona": (
"Eres entusiasta, persuasivo y centrado en el cliente. Haces preguntas de "
"descubrimiento reflexivas para entender las necesidades del prospecto, destacas "
"los beneficios relevantes del producto sin presionar, y facilitas que los "
"prospectos den el siguiente paso. Eres honesto sobre las limitaciones y escalas "
"las negociaciones de precios al equipo senior cuando se vuelven complejas."
),
},
"pt": {
"name": "Assistente de Vendas",
"description": (
"Um assistente de vendas entusiasmado que qualifica leads, responde perguntas sobre "
"produtos e agenda reuniões com a equipe comercial. Especializado em nutrir "
"prospects pelo funil, escalando negociações de preços complexas para a equipe sênior."
),
"persona": (
"Você é entusiasmado, persuasivo e focado no cliente. Você faz perguntas de "
"descoberta criteriosas para entender as necessidades do prospect, destaca os "
"benefícios relevantes do produto sem ser insistente, e facilita o próximo passo "
"para os prospects. Você é honesto sobre as limitações e escala as negociações de "
"preços para a equipe sênior quando ficam complexas."
),
},
},
# 3. Office Manager
"00000000-0000-0000-0000-000000000003": {
"es": {
"name": "Gerente de Oficina",
"description": (
"Un agente de operaciones altamente organizado que gestiona la programación, "
"solicitudes de instalaciones, coordinación con proveedores y tareas generales de "
"gestión de oficina. Mantiene el lugar de trabajo funcionando sin problemas y "
"escala asuntos sensibles de RRHH al equipo apropiado."
),
"persona": (
"Eres altamente organizado, proactivo y orientado al detalle. Anticipas las "
"necesidades antes de que se conviertan en problemas, te comunicas de forma clara "
"y concisa, y te responsabilizas de las tareas hasta su finalización. Eres "
"diplomático al manejar asuntos delicados y sabes cuándo involucrar a RRHH o a "
"la dirección."
),
},
"pt": {
"name": "Gerente de Escritório",
"description": (
"Um agente de operações altamente organizado que gerencia agendamentos, solicitações "
"de instalações, coordenação com fornecedores e tarefas gerais de gestão de "
"escritório. Mantém o ambiente de trabalho funcionando sem problemas e escala "
"assuntos sensíveis de RH para a equipe apropriada."
),
"persona": (
"Você é altamente organizado, proativo e orientado a detalhes. Você antecipa "
"necessidades antes que se tornem problemas, se comunica de forma clara e concisa, "
"e assume a responsabilidade pelas tarefas até a conclusão. Você é diplomático ao "
"lidar com assuntos delicados e sabe quando envolver o RH ou a liderança."
),
},
},
# 4. Project Coordinator
"00000000-0000-0000-0000-000000000004": {
"es": {
"name": "Coordinador de Proyectos",
"description": (
"Un coordinador de proyectos metódico que hace seguimiento de entregables, gestiona "
"cronogramas, coordina dependencias entre equipos y detecta riesgos a tiempo. "
"Mantiene a los interesados informados y escala plazos incumplidos a la "
"dirección del proyecto."
),
"persona": (
"Eres metódico, comunicativo y orientado a resultados. Desglosas proyectos "
"complejos en elementos de acción claros, haces seguimiento del progreso con "
"diligencia y detectas bloqueos de forma temprana. Comunicas actualizaciones de "
"estado claramente a los interesados en todos los niveles y mantienes la calma "
"cuando las prioridades cambian. Escalas riesgos y plazos incumplidos con "
"prontitud."
),
},
"pt": {
"name": "Coordenador de Projetos",
"description": (
"Um coordenador de projetos metódico que acompanha entregas, gerencia cronogramas, "
"coordena dependências entre equipes e identifica riscos antecipadamente. Mantém "
"os stakeholders informados e escala prazos perdidos para a liderança do projeto."
),
"persona": (
"Você é metódico, comunicativo e orientado a resultados. Você divide projetos "
"complexos em itens de ação claros, acompanha o progresso com diligência e "
"identifica bloqueios precocemente. Você comunica atualizações de status claramente "
"para os stakeholders em todos os níveis e mantém a calma quando as prioridades "
"mudam. Você escala riscos e prazos perdidos prontamente."
),
},
},
# 5. Financial Manager
"00000000-0000-0000-0000-000000000005": {
"es": {
"name": "Gerente Financiero",
"description": (
"Un agente financiero estratégico que gestiona presupuestos, proyecciones, reportes "
"financieros y análisis. Proporciona insights accionables a partir de datos "
"financieros y escala transacciones grandes o inusuales a la dirección para "
"su aprobación."
),
"persona": (
"Eres analítico, preciso y estratégico. Traduces datos financieros complejos en "
"insights y recomendaciones claras. Eres proactivo en la identificación de "
"variaciones presupuestarias, oportunidades de ahorro y riesgos financieros. "
"Mantienes estricta confidencialidad y escalas cualquier transacción que supere "
"los umbrales de aprobación."
),
},
"pt": {
"name": "Gerente Financeiro",
"description": (
"Um agente financeiro estratégico que gerencia orçamentos, previsões, relatórios "
"financeiros e análises. Fornece insights acionáveis a partir de dados financeiros "
"e escala transações grandes ou incomuns para a gerência sênior para aprovação."
),
"persona": (
"Você é analítico, preciso e estratégico. Você traduz dados financeiros complexos "
"em insights e recomendações claros. Você é proativo na identificação de variações "
"orçamentárias, oportunidades de redução de custos e riscos financeiros. Você "
"mantém estrita confidencialidade e escala quaisquer transações que excedam os "
"limites de aprovação."
),
},
},
# 6. Controller
"00000000-0000-0000-0000-000000000006": {
"es": {
"name": "Controller Financiero",
"description": (
"Un controller financiero riguroso que supervisa las operaciones contables, "
"asegura el cumplimiento de las regulaciones financieras, gestiona los procesos "
"de cierre mensual y monitorea la adherencia al presupuesto. Escala las "
"desviaciones presupuestarias a la dirección para su acción."
),
"persona": (
"Eres meticuloso, orientado al cumplimiento y autoritativo en materia financiera. "
"Aseguras que los registros financieros sean precisos, que los procesos se sigan "
"y que los controles se mantengan. Comunicas la posición financiera claramente "
"a la dirección y señalas los riesgos de cumplimiento de inmediato. Escalas "
"las desviaciones presupuestarias y fallos de control a los responsables "
"de decisiones apropiados."
),
},
"pt": {
"name": "Controller Financeiro",
"description": (
"Um controller financeiro rigoroso que supervisiona as operações contábeis, "
"garante a conformidade com as regulamentações financeiras, gerencia os processos "
"de fechamento mensal e monitora a aderência ao orçamento. Escala estouros "
"orçamentários para a liderança tomar providências."
),
"persona": (
"Você é meticuloso, focado em conformidade e autoritativo em assuntos financeiros. "
"Você garante que os registros financeiros sejam precisos, que os processos sejam "
"seguidos e que os controles sejam mantidos. Você comunica a posição financeira "
"claramente para a liderança e sinaliza riscos de conformidade imediatamente. "
"Você escala estouros orçamentários e falhas de controle para os tomadores "
"de decisão apropriados."
),
},
},
# 7. Accountant
"00000000-0000-0000-0000-000000000007": {
"es": {
"name": "Contador",
"description": (
"Un contador confiable que gestiona cuentas por pagar/cobrar, procesamiento de "
"facturas, conciliación de gastos y mantenimiento de registros financieros. "
"Asegura la precisión en todas las transacciones y escala discrepancias en "
"facturas para su revisión."
),
"persona": (
"Eres preciso, confiable y metódico. Procesas transacciones financieras con "
"cuidado, mantienes registros organizados y señalas discrepancias con prontitud. "
"Te comunicas claramente cuando falta información o hay inconsistencias, y sigues "
"los procedimientos contables establecidos con diligencia. Escalas discrepancias "
"importantes en facturas al controller o al gerente financiero."
),
},
"pt": {
"name": "Contador",
"description": (
"Um contador confiável que gerencia contas a pagar/receber, processamento de "
"faturas, conciliação de despesas e manutenção de registros financeiros. Garante "
"a precisão em todas as transações e escala discrepâncias em faturas para revisão."
),
"persona": (
"Você é preciso, confiável e metódico. Você processa transações financeiras com "
"cuidado, mantém registros organizados e sinaliza discrepâncias prontamente. "
"Você se comunica claramente quando as informações estão ausentes ou inconsistentes "
"e segue os procedimentos contábeis estabelecidos com diligência. Você escala "
"discrepâncias significativas de faturas para o controller ou gerente financeiro."
),
},
},
}
def upgrade() -> None:
# -------------------------------------------------------------------------
# 1. Add language column to portal_users
# -------------------------------------------------------------------------
op.add_column(
"portal_users",
sa.Column(
"language",
sa.String(10),
nullable=False,
server_default="en",
comment="UI and email language preference: en | es | pt",
),
)
# -------------------------------------------------------------------------
# 2. Add translations column to agent_templates
# -------------------------------------------------------------------------
op.add_column(
"agent_templates",
sa.Column(
"translations",
sa.JSON,
nullable=False,
server_default="{}",
comment="JSONB map of locale -> {name, description, persona} translations",
),
)
# -------------------------------------------------------------------------
# 3. Backfill translations for all 7 seed templates
# -------------------------------------------------------------------------
conn = op.get_bind()
for template_id, translations in _TEMPLATE_TRANSLATIONS.items():
conn.execute(
sa.text(
"UPDATE agent_templates "
"SET translations = CAST(:translations AS jsonb) "
"WHERE id = :id"
),
{
"id": template_id,
"translations": json.dumps(translations),
},
)
def downgrade() -> None:
op.drop_column("agent_templates", "translations")
op.drop_column("portal_users", "language")

Submodule packages/portal updated: c4ff491b9d...04c03749a6

View File

@@ -19,7 +19,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.rbac import PortalCaller, require_platform_admin, require_tenant_admin, require_tenant_member
from shared.api.rbac import PortalCaller, get_portal_caller, require_platform_admin, require_tenant_admin, require_tenant_member
from shared.db import get_session
from shared.models.audit import AuditEvent
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
@@ -47,6 +47,7 @@ class AuthVerifyResponse(BaseModel):
role: str
tenant_ids: list[str]
active_tenant_id: str | None
language: str = "en"
class AuthRegisterRequest(BaseModel):
@@ -302,6 +303,7 @@ async def verify_credentials(
role=user.role,
tenant_ids=tenant_ids,
active_tenant_id=active_tenant_id,
language=user.language,
)
@@ -842,3 +844,54 @@ async def stop_impersonation(
},
)
await session.commit()
# ---------------------------------------------------------------------------
# Language preference endpoint (Phase 7 multilanguage)
# ---------------------------------------------------------------------------
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
class LanguagePreferenceRequest(BaseModel):
language: str
class LanguagePreferenceResponse(BaseModel):
language: str
@portal_router.patch("/users/me/language", response_model=LanguagePreferenceResponse)
async def update_language_preference(
body: LanguagePreferenceRequest,
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> LanguagePreferenceResponse:
"""
Update the authenticated user's language preference.
Accepts: {"language": "es"} (must be one of: en, es, pt)
Returns: {"language": "es"} on success.
Returns 400 if language is not in the supported set.
Returns 401 if not authenticated (no X-Portal-User-Id header).
"""
if body.language not in _SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported language '{body.language}'. Supported: en, es, pt",
)
result = await session.execute(
select(PortalUser).where(PortalUser.id == caller.user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user.language = body.language
await session.commit()
return LanguagePreferenceResponse(language=body.language)

View File

@@ -10,6 +10,11 @@ Endpoints:
GET /api/portal/templates — list active templates (all authenticated users)
GET /api/portal/templates/{id} — get template detail (all authenticated users)
POST /api/portal/templates/{id}/deploy — deploy template as agent (tenant admin only)
Locale-aware responses:
Pass ?locale=es or ?locale=pt to receive translated name/description/persona fields.
English is the base — translations overlay, never replace stored English values in DB.
Unsupported locales silently fall back to English.
"""
from __future__ import annotations
@@ -18,7 +23,7 @@ import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -55,14 +60,33 @@ class TemplateResponse(BaseModel):
model_config = {"from_attributes": True}
@classmethod
def from_orm(cls, tmpl: AgentTemplate) -> "TemplateResponse":
def from_orm(cls, tmpl: AgentTemplate, locale: str = "en") -> "TemplateResponse":
"""
Build a TemplateResponse from an ORM AgentTemplate.
When locale != 'en' and the template's translations map contains the
locale key, translated name/description/persona fields are overlaid over
the English defaults. English base fields are never overwritten in the DB.
"""
name = tmpl.name
description = tmpl.description
persona = tmpl.persona
if locale != "en":
translations: dict[str, Any] = tmpl.translations or {}
locale_data: dict[str, Any] = translations.get(locale, {})
if locale_data:
name = locale_data.get("name", name)
description = locale_data.get("description", description)
persona = locale_data.get("persona", persona)
return cls(
id=str(tmpl.id),
name=tmpl.name,
name=name,
role=tmpl.role,
description=tmpl.description,
description=description,
category=tmpl.category,
persona=tmpl.persona,
persona=persona,
system_prompt=tmpl.system_prompt,
model_preference=tmpl.model_preference,
tool_assignments=tmpl.tool_assignments,
@@ -88,6 +112,7 @@ class TemplateDeployResponse(BaseModel):
@templates_router.get("/templates", response_model=list[TemplateResponse])
async def list_templates(
locale: str = Query(default="en", description="Response locale: en | es | pt"),
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> list[TemplateResponse]:
@@ -97,6 +122,9 @@ async def list_templates(
Available to all authenticated portal users (any role).
Templates are global — not tenant-scoped, no RLS needed.
Returns templates ordered by sort_order ascending, then name.
Pass ?locale=es or ?locale=pt to receive translated name/description/persona fields.
Unsupported locales fall back to English.
"""
result = await session.execute(
select(AgentTemplate)
@@ -104,12 +132,13 @@ async def list_templates(
.order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc())
)
templates = result.scalars().all()
return [TemplateResponse.from_orm(t) for t in templates]
return [TemplateResponse.from_orm(t, locale=locale) for t in templates]
@templates_router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: uuid.UUID,
locale: str = Query(default="en", description="Response locale: en | es | pt"),
caller: PortalCaller = Depends(get_portal_caller),
session: AsyncSession = Depends(get_session),
) -> TemplateResponse:
@@ -118,6 +147,8 @@ async def get_template(
Returns 404 if the template does not exist or is inactive.
Available to all authenticated portal users (any role).
Pass ?locale=es or ?locale=pt to receive translated fields.
"""
result = await session.execute(
select(AgentTemplate).where(
@@ -128,7 +159,7 @@ async def get_template(
tmpl = result.scalar_one_or_none()
if tmpl is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return TemplateResponse.from_orm(tmpl)
return TemplateResponse.from_orm(tmpl, locale=locale)
@templates_router.post(

View File

@@ -7,6 +7,9 @@ Phase 1 architectural constraint). Uses stdlib smtplib — no additional depende
If SMTP is not configured (empty smtp_host), logs a warning and returns without
sending. This allows the invitation flow to function in dev environments without
a mail server.
Supports localized invitation emails in English (en), Spanish (es), and Portuguese (pt).
Falls back to English for unsupported locales.
"""
from __future__ import annotations
@@ -20,35 +23,21 @@ from shared.config import settings
logger = logging.getLogger(__name__)
_SUPPORTED_LANGUAGES = {"en", "es", "pt"}
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
) -> None:
"""
Send an invitation email via SMTP.
# ---------------------------------------------------------------------------
# Localized email copy
# ---------------------------------------------------------------------------
Args:
to_email: Recipient email address.
invitee_name: Recipient's display name (for personalization).
tenant_name: Name of the tenant they're being invited to.
invite_url: The full invitation acceptance URL (includes raw token).
_SUBJECTS: dict[str, str] = {
"en": "You've been invited to join {tenant_name} on Konstruct",
"es": "Has sido invitado a unirte a {tenant_name} en Konstruct",
"pt": "Voce foi convidado para se juntar a {tenant_name} no Konstruct",
}
Note:
Called from a Celery task (sync). Silently skips if smtp_host is empty.
"""
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
subject = f"You've been invited to join {tenant_name} on Konstruct"
text_body = f"""Hi {invitee_name},
_TEXT_BODIES: dict[str, str] = {
"en": """\
Hi {invitee_name},
You've been invited to join {tenant_name} on Konstruct, the AI workforce platform.
@@ -61,9 +50,42 @@ This invitation expires in 48 hours.
If you did not expect this invitation, you can safely ignore this email.
— The Konstruct Team
"""
""",
"es": """\
Hola {invitee_name},
html_body = f"""<html>
Has sido invitado a unirte a {tenant_name} en Konstruct, la plataforma de empleados de IA.
Haz clic en el enlace a continuacion para aceptar tu invitacion y configurar tu cuenta:
{invite_url}
Esta invitacion vence en 48 horas.
Si no esperabas esta invitacion, puedes ignorar este correo de forma segura.
— El Equipo de Konstruct
""",
"pt": """\
Ola {invitee_name},
Voce foi convidado para se juntar a {tenant_name} no Konstruct, a plataforma de funcionarios de IA.
Clique no link abaixo para aceitar o seu convite e configurar sua conta:
{invite_url}
Este convite expira em 48 horas.
Se voce nao estava esperando este convite, pode ignorar este e-mail com seguranca.
— O Time Konstruct
""",
}
_HTML_BODIES: dict[str, str] = {
"en": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited to join {tenant_name}</h2>
<p>Hi {invitee_name},</p>
@@ -83,7 +105,96 @@ If you did not expect this invitation, you can safely ignore this email.
you can safely ignore it.
</p>
</body>
</html>"""
</html>""",
"es": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Has sido invitado a unirte a {tenant_name}</h2>
<p>Hola {invitee_name},</p>
<p>
Has sido invitado a unirte a <strong>{tenant_name}</strong> en
<strong>Konstruct</strong>, la plataforma de empleados de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceptar Invitacion
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Esta invitacion vence en 48 horas. Si no esperabas este correo,
puedes ignorarlo de forma segura.
</p>
</body>
</html>""",
"pt": """\
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Voce foi convidado para se juntar a {tenant_name}</h2>
<p>Ola {invitee_name},</p>
<p>
Voce foi convidado para se juntar a <strong>{tenant_name}</strong> no
<strong>Konstruct</strong>, a plataforma de funcionarios de IA.
</p>
<p>
<a href="{invite_url}"
style="display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 6px;">
Aceitar Convite
</a>
</p>
<p style="color: #6b7280; font-size: 0.9em;">
Este convite expira em 48 horas. Se voce nao estava esperando este e-mail,
pode ignora-lo com seguranca.
</p>
</body>
</html>""",
}
def send_invite_email(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
language: str = "en",
) -> None:
"""
Send an invitation email via SMTP, optionally in the inviter's language.
Args:
to_email: Recipient email address.
invitee_name: Recipient's display name (for personalization).
tenant_name: Name of the tenant they're being invited to.
invite_url: The full invitation acceptance URL (includes raw token).
language: Language for the email body. Supported: 'en', 'es', 'pt'.
Falls back to 'en' for unsupported locales.
Note:
Called from a Celery task (sync). Silently skips if smtp_host is empty.
"""
if not settings.smtp_host:
logger.warning(
"SMTP not configured (smtp_host is empty) — skipping invite email to %s",
to_email,
)
return
# Normalize language — fall back to English for unsupported locales
lang = language if language in _SUPPORTED_LANGUAGES else "en"
subject = _SUBJECTS[lang].format(tenant_name=tenant_name)
text_body = _TEXT_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
html_body = _HTML_BODIES[lang].format(
invitee_name=invitee_name,
tenant_name=tenant_name,
invite_url=invite_url,
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
@@ -101,7 +212,9 @@ If you did not expect this invitation, you can safely ignore this email.
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.smtp_from_email, [to_email], msg.as_string())
logger.info("Invite email sent to %s for tenant %s", to_email, tenant_name)
logger.info(
"Invite email sent to %s for tenant %s (language=%s)", to_email, tenant_name, lang
)
except Exception:
logger.exception(
"Failed to send invite email to %s (smtp_host=%s)",

View File

@@ -61,6 +61,12 @@ class PortalUser(Base):
default="customer_admin",
comment="platform_admin | customer_admin | customer_operator",
)
language: Mapped[str] = mapped_column(
String(10),
nullable=False,
server_default="en",
comment="UI and email language preference: en | es | pt",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,

View File

@@ -253,6 +253,12 @@ class AgentTemplate(Base):
default=True,
comment="Inactive templates are hidden from the gallery",
)
translations: Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="JSONB map of locale -> {name, description, persona} translations. E.g. {'es': {...}, 'pt': {...}}",
)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,

View File

@@ -14,6 +14,14 @@ AI_TRANSPARENCY_CLAUSE = (
"When directly asked if you are an AI, always disclose that you are an AI assistant."
)
# Language detection instruction (Phase 7 multilanguage feature).
# Instructs agents to respond in the language the user writes in.
# Supports English, Spanish, and Portuguese.
LANGUAGE_INSTRUCTION = (
"Detect the language of each user message and respond in that same language. "
"You support English, Spanish, and Portuguese."
)
def build_system_prompt(
name: str,
@@ -62,6 +70,9 @@ def build_system_prompt(
)
sections.append(f"Escalation rules:\n{rule_lines}")
# --- Language instruction (always present — Phase 7 multilanguage) ---
sections.append(LANGUAGE_INSTRUCTION)
# --- AI transparency clause (always present, non-negotiable) ---
sections.append(AI_TRANSPARENCY_CLAUSE)

View File

@@ -0,0 +1,186 @@
"""
Integration tests for the language preference PATCH endpoint.
Tests:
- PATCH /api/portal/users/me/language with valid language returns 200
- PATCH with unsupported language returns 400
- PATCH to "pt" then GET /api/portal/auth/verify includes language="pt"
- PATCH without auth returns 401
Uses the same pattern as existing integration tests:
- Session override via app.dependency_overrides
- X-Portal-User-Id / X-Portal-User-Role header injection for auth
- db_session fixture from tests/conftest.py (Alembic migrations applied)
"""
from __future__ import annotations
import uuid
import bcrypt
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.portal import portal_router
from shared.db import get_session
from shared.models.auth import PortalUser
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def make_app(session: AsyncSession) -> FastAPI:
"""Build a minimal FastAPI test app with portal router."""
app = FastAPI()
app.include_router(portal_router)
async def override_get_session(): # type: ignore[return]
yield session
app.dependency_overrides[get_session] = override_get_session
return app
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def auth_headers(user_id: uuid.UUID, role: str = "customer_admin") -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": role,
}
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
async def _create_user(
session: AsyncSession,
role: str = "customer_admin",
language: str = "en",
) -> PortalUser:
suffix = uuid.uuid4().hex[:8]
hashed = bcrypt.hashpw(b"testpassword123", bcrypt.gensalt()).decode()
user = PortalUser(
id=uuid.uuid4(),
email=f"langtest-{suffix}@example.com",
hashed_password=hashed,
name=f"Language Test User {suffix}",
role=role,
language=language,
)
session.add(user)
await session.flush()
return user
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def lang_client(db_session: AsyncSession) -> AsyncClient:
"""HTTP client with portal router mounted."""
app = make_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def lang_user(db_session: AsyncSession) -> PortalUser:
"""Create a customer_admin user with default language 'en'."""
user = await _create_user(db_session, role="customer_admin", language="en")
await db_session.commit()
return user
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_patch_language_valid(
lang_client: AsyncClient,
lang_user: PortalUser,
) -> None:
"""PATCH /api/portal/users/me/language with valid language returns 200 and new language."""
response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "es"},
headers=auth_headers(lang_user.id),
)
assert response.status_code == 200
data = response.json()
assert data["language"] == "es"
@pytest.mark.asyncio
async def test_patch_language_invalid(
lang_client: AsyncClient,
lang_user: PortalUser,
) -> None:
"""PATCH with unsupported language 'fr' returns 400."""
response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "fr"},
headers=auth_headers(lang_user.id),
)
assert response.status_code == 400
assert "fr" in response.json()["detail"].lower() or "unsupported" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_patch_language_persists(
lang_client: AsyncClient,
lang_user: PortalUser,
db_session: AsyncSession,
) -> None:
"""PATCH to 'pt', then GET /api/portal/auth/verify includes language='pt'."""
# First PATCH to "pt"
patch_response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "pt"},
headers=auth_headers(lang_user.id),
)
assert patch_response.status_code == 200
assert patch_response.json()["language"] == "pt"
# Verify via /auth/verify — need to pass email+password
# Re-fetch user to get credentials, then call auth/verify
verify_response = await lang_client.post(
"/api/portal/auth/verify",
json={"email": lang_user.email, "password": "testpassword123"},
)
assert verify_response.status_code == 200
verify_data = verify_response.json()
assert verify_data["language"] == "pt", (
f"Expected language='pt' in auth/verify response, got: {verify_data.get('language')!r}"
)
@pytest.mark.asyncio
async def test_patch_language_unauthenticated(
lang_client: AsyncClient,
) -> None:
"""PATCH without auth headers returns 401 or 422 (missing required headers)."""
response = await lang_client.patch(
"/api/portal/users/me/language",
json={"language": "es"},
)
# FastAPI raises 422 when required headers are missing entirely (before auth guard runs).
# Both 401 and 422 are acceptable rejections of unauthenticated requests.
assert response.status_code in (401, 422)

View File

@@ -0,0 +1,226 @@
"""
Integration tests for locale-aware template API endpoints.
Tests:
- GET /api/portal/templates (no locale) returns English fields
- GET /api/portal/templates?locale=es returns Spanish-translated fields
- GET /api/portal/templates?locale=pt returns Portuguese-translated fields
- GET /api/portal/templates?locale=fr falls back to English
- Translated fields overlay English base, English values still in DB
Uses the same pattern as existing integration tests:
- Session override via app.dependency_overrides
- X-Portal-User-Id / X-Portal-User-Role header injection
- db_session fixture from tests/conftest.py (Alembic migrations applied)
"""
from __future__ import annotations
import uuid
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.portal import portal_router
from shared.api.templates import templates_router
from shared.db import get_session
from shared.models.tenant import AgentTemplate
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def make_app(session: AsyncSession) -> FastAPI:
"""Build a minimal FastAPI test app with portal + templates routers."""
app = FastAPI()
app.include_router(portal_router)
app.include_router(templates_router)
async def override_get_session(): # type: ignore[return]
yield session
app.dependency_overrides[get_session] = override_get_session
return app
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def platform_admin_headers(user_id: uuid.UUID) -> dict[str, str]:
return {
"X-Portal-User-Id": str(user_id),
"X-Portal-User-Role": "platform_admin",
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def i18n_client(db_session: AsyncSession) -> AsyncClient:
"""HTTP client with portal + templates router mounted."""
app = make_app(db_session)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
def admin_user_id() -> uuid.UUID:
"""Fixed UUID for a fake platform_admin — no DB row needed for header-based auth."""
return uuid.UUID("00000000-0000-0000-0000-000000000099")
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_templates_default_locale(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates (no locale param) returns English fields."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# All returned names should be English (Customer Support Rep is the first by sort_order)
names = {t["name"] for t in templates}
assert "Customer Support Rep" in names, f"Expected English template name, got: {names}"
@pytest.mark.asyncio
async def test_list_templates_spanish(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates?locale=es returns Spanish-translated name/description/persona."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates?locale=es", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# Find the Customer Support Rep template (ID: 000...001)
support_tmpl = next(
(t for t in templates if "soporte" in t["name"].lower() or "support" in t["name"].lower()),
None,
)
assert support_tmpl is not None, f"Customer support template not found in: {[t['name'] for t in templates]}"
# Spanish name should differ from English
assert support_tmpl["name"] != "Customer Support Rep", (
f"Expected Spanish translation, got English name: {support_tmpl['name']!r}"
)
# Spanish description should be present and non-empty
assert len(support_tmpl["description"]) > 10
@pytest.mark.asyncio
async def test_list_templates_portuguese(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates?locale=pt returns Portuguese-translated fields."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates?locale=pt", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# Find the Customer Support Rep template
support_tmpl = next(
(t for t in templates if "suporte" in t["name"].lower() or "support" in t["name"].lower()),
None,
)
assert support_tmpl is not None, f"Customer support template not found in: {[t['name'] for t in templates]}"
# Portuguese name should differ from English
assert support_tmpl["name"] != "Customer Support Rep", (
f"Expected Portuguese translation, got English name: {support_tmpl['name']!r}"
)
# Portuguese description should be present and non-empty
assert len(support_tmpl["description"]) > 10
@pytest.mark.asyncio
async def test_list_templates_unsupported_locale(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
) -> None:
"""GET /api/portal/templates?locale=fr falls back to English."""
headers = platform_admin_headers(admin_user_id)
response = await i18n_client.get("/api/portal/templates?locale=fr", headers=headers)
assert response.status_code == 200
templates = response.json()
assert len(templates) >= 7
# Names should be English (fallback)
names = {t["name"] for t in templates}
assert "Customer Support Rep" in names, (
f"Expected English fallback for unsupported locale 'fr', got names: {names}"
)
@pytest.mark.asyncio
async def test_template_translations_overlay(
i18n_client: AsyncClient,
admin_user_id: uuid.UUID,
db_session: AsyncSession,
) -> None:
"""Translated fields overlay English, English base fields still in DB."""
headers = platform_admin_headers(admin_user_id)
# Get Spanish-translated templates
es_response = await i18n_client.get("/api/portal/templates?locale=es", headers=headers)
assert es_response.status_code == 200
es_templates = es_response.json()
# Get English templates (default)
en_response = await i18n_client.get("/api/portal/templates", headers=headers)
assert en_response.status_code == 200
en_templates = en_response.json()
# Find the support template in both
es_support = next((t for t in es_templates if "soporte" in t["name"].lower()), None)
en_support = next((t for t in en_templates if t["name"] == "Customer Support Rep"), None)
assert es_support is not None, "Spanish support template not found"
assert en_support is not None, "English support template not found"
# They should share the same template ID
assert es_support["id"] == en_support["id"], "Template IDs should match across locales"
# Names should differ between locales
assert es_support["name"] != en_support["name"], (
"Spanish and English names should differ for Customer Support Rep template"
)
# English base values must still be present in DB (not overwritten)
result = await db_session.execute(
select(AgentTemplate).where(
AgentTemplate.id == uuid.UUID(en_support["id"])
)
)
tmpl_orm = result.scalar_one_or_none()
assert tmpl_orm is not None
assert tmpl_orm.name == "Customer Support Rep", (
f"DB English name should be unchanged, got: {tmpl_orm.name!r}"
)

View File

@@ -29,6 +29,7 @@ def _make_user(role: str, email: str = "test@example.com") -> PortalUser:
user.email = email
user.name = "Test User"
user.role = role
user.language = "en"
# Real bcrypt hash for password "testpassword"
user.hashed_password = bcrypt.hashpw(b"testpassword", bcrypt.gensalt()).decode()
user.created_at = datetime.now(tz=timezone.utc)

View File

@@ -166,3 +166,43 @@ class TestBuildSystemPromptAIClauseAlwaysPresent:
def test_ai_clause_present_with_persona_only(self) -> None:
prompt = build_system_prompt(name="Sam", role="Analyst", persona="Detail-oriented")
assert AI_TRANSPARENCY_CLAUSE in prompt
class TestLanguageInstruction:
"""LANGUAGE_INSTRUCTION must be present in all system prompts before AI transparency clause."""
LANGUAGE_INSTRUCTION = (
"Detect the language of each user message and respond in that same language. "
"You support English, Spanish, and Portuguese."
)
def test_language_instruction_present_in_default_prompt(self) -> None:
"""build_system_prompt with name+role includes LANGUAGE_INSTRUCTION."""
prompt = build_system_prompt(name="Mara", role="Support Rep")
assert self.LANGUAGE_INSTRUCTION in prompt
def test_language_instruction_present_with_full_args(self) -> None:
"""build_system_prompt with all args includes LANGUAGE_INSTRUCTION."""
prompt = build_system_prompt(
name="Mara",
role="Support Rep",
persona="Helpful and professional",
tool_assignments=["knowledge_base_search"],
escalation_rules=[{"condition": "billing_dispute", "action": "handoff_human"}],
)
assert self.LANGUAGE_INSTRUCTION in prompt
def test_language_instruction_before_transparency_clause(self) -> None:
"""LANGUAGE_INSTRUCTION appears before AI_TRANSPARENCY_CLAUSE in the prompt."""
prompt = build_system_prompt(
name="Mara",
role="Support Rep",
persona="Helpful",
tool_assignments=["kb_search"],
escalation_rules=[{"condition": "x", "action": "handoff_human"}],
)
lang_pos = prompt.index(self.LANGUAGE_INSTRUCTION)
transparency_pos = prompt.index(AI_TRANSPARENCY_CLAUSE)
assert lang_pos < transparency_pos, (
"LANGUAGE_INSTRUCTION must appear before AI_TRANSPARENCY_CLAUSE"
)