CatalyzeUpDocs
impact pulse / technical

Survey Translation - Implementation Spec

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 id and type (using const)
  • 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:

  1. Cost control. Each translation costs an AI call. We don't want to trigger translations on every save or keystroke.
  2. Predictability. The admin decides when to translate and which languages to support.
  3. 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)

  1. Admin opens survey editor, goes to "Languages" tab
  2. Clicks "Add French"
  3. Backend calls AI, generates all translations in one batch
  4. Translations are stored immediately with status: "ai_generated"
  5. Admin can review/edit, then mark as status: "approved"
  6. Survey is now available in French

Scenario 2: Admin edits a question after translation exists

  1. Admin changes question text from "How are you?" to "How are you feeling?"
  2. Backend marks the French translation of that question as status: "stale"
  3. Admin sees a yellow "needs re-translation" indicator
  4. Admin can click "Re-translate" to update just that question
  5. 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:

  1. Check URL ?lang= parameter
  2. Check browser navigator.language
  3. 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)

  1. Add translations, default_language, available_languages, translation_status fields to survey model
  2. Add translations field to question model
  3. Update GET /surveys/public/{id} to accept ?lang= and swap content
  4. Add PATCH /surveys/{id}/translations/{lang} for manual translation input
  5. Add DELETE /surveys/{id}/translations/{lang}

This phase lets admins manually enter translations. No AI needed.

Phase 2: AI Translation

  1. Add POST /surveys/{id}/translate endpoint
  2. Build extract_translatable_content() function
  3. Build AI prompt, call Azure OpenAI, parse response
  4. Store translations and set status to ai_generated
  5. Handle stale detection when source content changes

Phase 3: Frontend Language Picker + Admin UI

  1. Language picker on survey take page
  2. "Languages" tab in survey editor
  3. Side-by-side translation review UI
  4. "Add Language" button triggering AI translation
  5. Stale translation indicators

Phase 4: Platform UI Translation (i18next)

  1. Install react-i18next
  2. Extract hardcoded strings to en.json
  3. Use AI to generate initial translations for target languages
  4. Language selector in user settings

Phase 5: RTL Support

  1. dir="rtl" on survey container for Arabic/Farsi/Hebrew
  2. Tailwind RTL variants for directional components
  3. Test all question types in RTL mode

Open Questions

  1. Should the COUNTRY question type auto-translate country names? The react-country-region-selector data 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.

  2. 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.

  3. 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.

  4. 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.