Survey Translation - Implementation Spec
Technical implementation guide for multi-language survey support. This builds on the product spec at Multi-Language Support. Read that first.
Core Decisions
English is the source of truth. Every survey is authored in English. Translations are derived copies. Scoring always uses English keys. If a translation is missing, the English original is shown.
Translations are pre-generated, not on-the-fly. When an admin adds a language, the AI translates everything immediately and stores the result. Survey takers never wait for a translation.
Survey-level language control. Each survey declares which languages it supports via available_languages. The survey creator decides. An organization can set preferred languages that pre-populate for new surveys.
Translations are stored alongside the original. No separate translation table. Each translatable field gets a sibling _translations field on the same document. This keeps reads fast (one query, no joins).
What Gets Translated
Only user-visible text that a survey taker sees. Nothing else.
| Field | Location | Type |
|---|---|---|
| Survey title | surveys.title |
string |
| Survey description | surveys.description |
string |
| Question text | questions.text |
string |
| Question options | questions.options |
string[] |
| Rating labels | questions.rating_labels |
Record<string, string> |
| Group name | surveys.question_groups[].name |
string |
| Group description | surveys.question_groups[].description |
string |
Not translated: Question IDs, scoring configs, weights, survey settings, status values, user data.
Data Model Changes
Survey Collection (new fields)
{
// Existing fields
title: "Community Impact Assessment",
description: "Assess the impact of our programs on communities",
question_groups: [
{ id: "g1", name: "About You", description: "Tell us where you are", ... },
{ id: "g2", name: "Vital", description: "", ... }
],
// New fields
default_language: "en",
available_languages: ["en", "fr", "es"],
translations: {
"fr": {
"title": "Evaluation de l'impact communautaire",
"description": "Evaluez l'impact de nos programmes sur les communautes",
"groups": {
"g1": { "name": "A propos de vous", "description": "Dites-nous ou vous etes" },
"g2": { "name": "Vital", "description": "" }
}
},
"es": {
"title": "Evaluacion del impacto comunitario",
"description": "Evalue el impacto de nuestros programas",
"groups": {
"g1": { "name": "Acerca de ti", "description": "Dinos donde estas" },
"g2": { "name": "Vital", "description": "" }
}
}
}
}
Question Collection (new fields)
{
// Existing fields
id: "q1",
text: "How satisfied are you with the program?",
type: "RATING",
options: [],
rating_labels: { "0": "N/A", "1": "Poor", "5": "Excellent" },
// New fields
translations: {
"fr": {
"text": "Dans quelle mesure etes-vous satisfait du programme?",
"rating_labels": { "0": "N/A", "1": "Faible", "5": "Excellent" }
}
}
}
For questions with options (SINGLE_CHOICE, MULTIPLE_CHOICE):
{
text: "What is your biggest challenge?",
type: "SINGLE_CHOICE",
options: ["School", "Family", "Friends", "Career"],
translations: {
"fr": {
"text": "Quel est votre plus grand defi?",
"options": ["Ecole", "Famille", "Amis", "Carriere"]
}
}
}
Key rule: translations.fr.options must have the same length and order as options. The index maps 1:1. When the user selects the French option "Ecole", the response stores "School" (the English key at index 0).
API Changes
Get Translated Survey (for survey takers)
GET /api/v1/surveys/public/{public_id}?lang=fr
The backend returns the survey with translations applied. If lang is omitted, returns default language. If the requested language is not in available_languages, returns default.
The response shape does not change. The backend swaps title, description, text, options, rating_labels, and group names/descriptions to the requested language. The frontend does not need to know about translations at all during survey taking.
This means the QuestionRenderer, SurveyTakePage, and scoring logic stay untouched.
Trigger Translation
POST /api/v1/surveys/{id}/translate
{
"target_language": "fr",
"survey_description_for_ai": "This survey measures the well-being of refugees in Canadian communities"
}
Response:
{
"language": "fr",
"status": "completed",
"translated_fields": 28,
"review_url": "/surveys/{id}/translations/fr"
}
The survey_description_for_ai is an optional context hint for the AI. It is NOT the survey description field. It tells the AI what the survey is about so it can choose culturally appropriate translations.
Remove Language
DELETE /api/v1/surveys/{id}/translations/{lang}
Removes translations for that language. Does not affect existing responses.
Update Single Translation (manual edit)
PATCH /api/v1/surveys/{id}/translations/{lang}
{
"survey_title": "Evaluation de l'impact",
"questions": {
"q1": { "text": "Better translation here" }
}
}
For admins who want to manually fix an AI translation.
AI Translation: What Gets Sent
The translation endpoint extracts user-visible text and sends it to the AI as a structured JSON. The structure preserves the relationship between questions, their type, and their options, so the AI understands context (e.g., that "School" is an answer to "What is your biggest challenge?" and not a standalone word).
Extraction (backend builds this before calling AI)
def extract_translatable_content(survey, questions):
"""Extract user-visible strings with survey structure for context."""
content = {
"survey_title": survey["title"],
"survey_description": survey["description"] or "",
"sections": [],
"questions": []
}
for group in survey.get("question_groups", []):
content["sections"].append({
"id": group["id"],
"name": group["name"],
"description": group.get("description", "")
})
for q in questions:
entry = {
"id": q["id"],
"type": q["type"],
"text": q["text"],
"section": q.get("group_id")
}
if q.get("options"):
entry["options"] = q["options"]
if q.get("rating_labels"):
entry["rating_labels"] = q["rating_labels"]
content["questions"].append(entry)
return content
Full Example: What the AI Receives
For the Happiness Index Test survey translated to French, the AI receives this exact payload:
{
"survey_title": "Happiness Index Test (HIT)",
"survey_description": "Maslow Needs Assessment - migrated from happiness.wehappers.org",
"sections": [
{ "id": "about_you", "name": "About You", "description": "Tell us where you are located" },
{ "id": "vital", "name": "Vital", "description": "" },
{ "id": "physiological", "name": "Physiological", "description": "" },
{ "id": "safety_development", "name": "Safety & Development", "description": "" },
{ "id": "love_belonging", "name": "Love & Belonging", "description": "" },
{ "id": "esteem", "name": "Esteem", "description": "" },
{ "id": "self_actualization", "name": "Self-Actualization", "description": "" }
],
"questions": [
{
"id": "q_country",
"type": "COUNTRY",
"text": "What country are you in?",
"section": "about_you"
},
{
"id": "q1",
"type": "RATING",
"text": "How would you rate your access to water?",
"section": "vital",
"rating_labels": {
"0": "N/A",
"1": "Very Poor",
"2": "Poor",
"3": "Fair",
"4": "Good",
"5": "Excellent"
}
},
{
"id": "q5",
"type": "SINGLE_CHOICE",
"text": "What is your biggest challenge right now?",
"section": "safety_development",
"options": ["School", "Family", "Friends", "Self-esteem", "Career planning"]
},
{
"id": "q10",
"type": "BOOLEAN",
"text": "Do you feel safe in your community?",
"section": "safety_development"
}
]
}
The AI sees the full structure: each question has a type, belongs to a section, and has its options in context. This lets the AI make better translation choices. For example, it knows "School" is an answer to "What is your biggest challenge?" so it translates it as "L'ecole" (the institution) rather than "Ecole" (the building).
What the AI Returns
The AI returns the exact same structure with translated values:
{
"survey_title": "Test de l'indice de bonheur (HIT)",
"survey_description": "Evaluation des besoins de Maslow",
"sections": [
{ "id": "about_you", "name": "A propos de vous", "description": "Dites-nous ou vous vous trouvez" },
{ "id": "vital", "name": "Vital", "description": "" },
{ "id": "physiological", "name": "Physiologique", "description": "" },
{ "id": "safety_development", "name": "Securite et developpement", "description": "" },
{ "id": "love_belonging", "name": "Amour et appartenance", "description": "" },
{ "id": "esteem", "name": "Estime", "description": "" },
{ "id": "self_actualization", "name": "Realisation de soi", "description": "" }
],
"questions": [
{
"id": "q_country",
"type": "COUNTRY",
"text": "Dans quel pays vous trouvez-vous?"
},
{
"id": "q1",
"type": "RATING",
"text": "Comment evaluez-vous votre acces a l'eau?",
"rating_labels": {
"0": "N/A",
"1": "Tres mauvais",
"2": "Mauvais",
"3": "Passable",
"4": "Bon",
"5": "Excellent"
}
},
{
"id": "q5",
"type": "SINGLE_CHOICE",
"text": "Quel est votre plus grand defi en ce moment?",
"options": ["L'ecole", "La famille", "Les amis", "L'estime de soi", "La planification de carriere"]
},
{
"id": "q10",
"type": "BOOLEAN",
"text": "Vous sentez-vous en securite dans votre communaute?"
}
]
}
The IDs are preserved (not translated). The type field is preserved. The section references are preserved. Only the human-readable strings are translated.
AI Prompt Design
SYSTEM PROMPT:
You are a professional translator for nonprofit impact measurement surveys.
Context about this survey: {survey_description_for_ai}
Rules:
- Translate from {source_language} to {target_language}.
- Use simple, everyday language. Beneficiaries may have limited literacy.
- Be culturally appropriate for {target_language} speakers.
- Keep translations concise. Do not add explanations.
- For rating labels (like "Poor", "Good", "Excellent"), use the standard
equivalent in the target language.
- For option lists, translate each option and maintain the exact same order.
The options are answers to the question they belong to. Translate them
in context of the question, not as isolated words.
- Preserve all "id", "type", and "section" fields exactly as they are.
Only translate human-readable text: survey_title, survey_description,
section names/descriptions, question text, options, and rating_labels values.
- Return ONLY valid JSON with the exact same structure as the input.
- Do not add or remove any keys.
USER PROMPT:
Translate this survey content to {target_language}:
{json.dumps(translatable_content, indent=2)}
Structured Output (JSON Schema Enforcement)
Use OpenAI's response_format: { type: "json_schema" } to guarantee the AI returns valid, correctly-shaped JSON. This eliminates parsing failures entirely. The schema is generated dynamically per survey based on the actual questions.
def build_translation_schema(survey, questions):
"""Build a JSON schema that matches this specific survey's structure."""
# Build question schemas (each question has fixed fields based on its type)
question_schemas = []
for q in questions:
q_props = {
"id": {"type": "string", "const": q["id"]}, # Must not change
"type": {"type": "string", "const": q["type"]}, # Must not change
"text": {"type": "string"} # Translated
}
required = ["id", "type", "text"]
if q.get("options"):
# Fixed-length tuple: same number of options, same order
q_props["options"] = {
"type": "array",
"items": {"type": "string"},
"minItems": len(q["options"]),
"maxItems": len(q["options"])
}
required.append("options")
if q.get("rating_labels"):
# Same keys (0-5), translated values
q_props["rating_labels"] = {
"type": "object",
"properties": {k: {"type": "string"} for k in q["rating_labels"]},
"required": list(q["rating_labels"].keys())
}
required.append("rating_labels")
question_schemas.append({
"type": "object",
"properties": q_props,
"required": required,
"additionalProperties": False
})
# Build section schemas
section_schemas = []
for g in survey.get("question_groups", []):
section_schemas.append({
"type": "object",
"properties": {
"id": {"type": "string", "const": g["id"]},
"name": {"type": "string"},
"description": {"type": "string"}
},
"required": ["id", "name", "description"],
"additionalProperties": False
})
return {
"name": "survey_translation",
"strict": True,
"schema": {
"type": "object",
"properties": {
"survey_title": {"type": "string"},
"survey_description": {"type": "string"},
"sections": {
"type": "array",
"items": {"oneOf": section_schemas},
"minItems": len(section_schemas),
"maxItems": len(section_schemas)
},
"questions": {
"type": "array",
"items": {"oneOf": question_schemas},
"minItems": len(question_schemas),
"maxItems": len(question_schemas)
}
},
"required": ["survey_title", "survey_description", "sections", "questions"],
"additionalProperties": False
}
}
What the schema enforces at the API level (zero validation code needed):
- Exact same number of sections and questions
- Each question keeps its original
idandtype(usingconst) - Options arrays have exactly the right length
- Rating labels have exactly the right keys
- No extra or missing fields
What we still validate after (business rules):
- No empty strings where the original had content
- Translated text is different from the source (catch passthrough failures)
- IDs match in the same order
OpenAI API Call
response = openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Translate to {target_language}:\n\n{json.dumps(content, indent=2)}"}
],
response_format={
"type": "json_schema",
"json_schema": build_translation_schema(survey, questions)
}
)
translated = json.loads(response.choices[0].message.content)
The schema enforcement is free (same price, same speed). It uses constrained decoding at the token level, so the model literally cannot produce invalid JSON.
AI Service Choice
Use Azure OpenAI (GPT-4o) for translation. Reasons:
- Already in the Azure stack
- Structured outputs supported (response_format with json_schema)
- Fast for structured translation tasks
- Good at following JSON output format
Endpoint: POST https://{resource}.openai.azure.com/openai/deployments/gpt-4o/chat/completions
Fallback: Claude API via Anthropic (supports tool_use for structured output). Anthropic does not have json_schema response_format, but tool definitions achieve the same effect.
Translation is Always Explicit (Button, Not Automatic)
Translations are never generated automatically or on the fly. The admin must explicitly click a "Generate Translation" button. This is intentional:
- Cost control. Each translation costs an AI call. We don't want to trigger translations on every save or keystroke.
- Predictability. The admin decides when to translate and which languages to support.
- Quality. The admin can review the AI output before making it available to survey takers.
The only exception: organizations can enable "auto-translate on publish" in their settings. When a survey is published, it automatically triggers translation for all preferred languages. But this is opt-in, not default.
When Translations Are Triggered
Scenario 1: Admin adds a language (most common)
- Admin opens survey editor, goes to "Languages" tab
- Clicks "Add French"
- Backend calls AI, generates all translations in one batch
- Translations are stored immediately with
status: "ai_generated" - Admin can review/edit, then mark as
status: "approved" - Survey is now available in French
Scenario 2: Admin edits a question after translation exists
- Admin changes question text from "How are you?" to "How are you feeling?"
- Backend marks the French translation of that question as
status: "stale" - Admin sees a yellow "needs re-translation" indicator
- Admin can click "Re-translate" to update just that question
- Or admin can manually edit the French translation
Scenario 3: Survey published, then new language added
Works the same as Scenario 1. The AI translates the published version. New respondents in that language see the translation immediately. Existing responses are not affected (they already stored English keys).
Scenario 4: Organization adds a preferred language
When the org sets preferred languages (e.g., ["en", "fr", "es"]), new surveys automatically get those languages added. The AI translates on survey creation.
Translation Status Tracking
Each translation has a status per language per field:
translation_status: {
"fr": {
"status": "approved", // overall status
"translated_at": "2026-04-12T...",
"approved_by": "user_id",
"stale_fields": [] // empty = all up to date
},
"es": {
"status": "ai_generated",
"translated_at": "2026-04-12T...",
"stale_fields": ["q3", "title"] // these need re-translation
}
}
Statuses: ai_generated (new, needs review), approved (human verified), stale (source changed since translation).
Frontend: Language Picker on Survey Take Page
The survey take page gets a language picker only if available_languages has more than one entry.
// At top of survey intro page, before "Begin Survey"
{survey.available_languages.length > 1 && (
<LanguagePicker
languages={survey.available_languages}
selected={currentLang}
onChange={(lang) => {
setCurrentLang(lang)
// Re-fetch survey with ?lang=fr
refetchSurvey(lang)
}}
/>
)}
The language picker is a simple dropdown. When changed, the page re-fetches the survey with the new ?lang= parameter. All text updates. Responses already entered are preserved (they're stored as English keys/values, not translated text).
Auto-detection on first load:
- Check URL
?lang=parameter - Check browser
navigator.language - Fall back to survey's
default_language
Frontend: Admin Translation Editor
New tab in the survey editor: "Languages"
+-----------------------------------------+
| Survey Builder [DRAFT] |
| |
| [Settings] [Questions] [Languages] |
+-----------------------------------------+
| Languages |
| |
| English (default) SOURCE |
| French [approved] [Edit] [x] |
| Spanish [needs review] [Edit][x] |
| |
| [+ Add Language] |
+-----------------------------------------+
"Edit" opens a side-by-side view:
+-------------------+---------------------+
| English (source) | French |
+-------------------+---------------------+
| Title: | Title: |
| Community Impact | Impact communautaire|
| Assessment | |
+-------------------+---------------------+
| Q1: How satisfied | Q1: Dans quelle |
| are you? | mesure etes-vous |
| | satisfait? |
+-------------------+---------------------+
| Options: | Options: |
| - Very satisfied | - Tres satisfait |
| - Satisfied | - Satisfait |
| - Neutral | - Neutre |
+-------------------+---------------------+
| [Approve All] [Re-translate] |
+-------------------+---------------------+
Implementation Order
Phase 1: Backend Translation Storage (no AI yet)
- Add
translations,default_language,available_languages,translation_statusfields to survey model - Add
translationsfield to question model - Update
GET /surveys/public/{id}to accept?lang=and swap content - Add
PATCH /surveys/{id}/translations/{lang}for manual translation input - Add
DELETE /surveys/{id}/translations/{lang}
This phase lets admins manually enter translations. No AI needed.
Phase 2: AI Translation
- Add
POST /surveys/{id}/translateendpoint - Build
extract_translatable_content()function - Build AI prompt, call Azure OpenAI, parse response
- Store translations and set status to
ai_generated - Handle stale detection when source content changes
Phase 3: Frontend Language Picker + Admin UI
- Language picker on survey take page
- "Languages" tab in survey editor
- Side-by-side translation review UI
- "Add Language" button triggering AI translation
- Stale translation indicators
Phase 4: Platform UI Translation (i18next)
- Install react-i18next
- Extract hardcoded strings to
en.json - Use AI to generate initial translations for target languages
- Language selector in user settings
Phase 5: RTL Support
dir="rtl"on survey container for Arabic/Farsi/Hebrew- Tailwind RTL variants for directional components
- Test all question types in RTL mode
Open Questions
-
Should the COUNTRY question type auto-translate country names? The
react-country-region-selectordata is English-only. We could maintain a mapping or use the AI to translate country lists per language. Or we could use ISO country codes and a standard i18n country name library. -
Translation memory across surveys. If two surveys have the same question "How satisfied are you?", should we reuse the existing French translation? A simple lookup table could save AI calls and ensure consistency.
-
Cost estimation. A typical survey has ~30 translatable strings. GPT-4o costs ~$5/1M input tokens. Translating one survey to one language is ~500 tokens = ~$0.0025. Translating to 10 languages = ~$0.025 per survey. Negligible.
-
Offline/export. Should admins be able to export translations as CSV (for professional translators) and re-import? Useful for organizations that have bilingual staff who prefer spreadsheets.