> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Open-Ended Response → Focus Group Questions

> Extract themes from Typeform open-ended answers and run Mavera Focus Groups for qualitative depth on each theme

### Scenario

Your Typeform survey includes open-ended questions that generated hundreds of free-text responses. Reading them all is impractical, and keyword analysis misses nuance. This job extracts all open-ended answers, sends them to Mavera Chat for theme identification, then creates a Focus Group with targeted questions based on the discovered themes. The result is a deep-dive into the *why* behind each theme, with synthetic personas probing the nuances your survey couldn't capture.

**Flow:** Typeform responses → Filter open-ended fields → Mavera Chat: "Identify themes" → Parse themes → `POST /api/v1/focus-groups` with theme-specific questions → Qualitative depth on each theme

### Architecture

```mermaid theme={"dark"}
flowchart LR
A["Extract open-ended text answers"] --> B["Mavera Chat: Identify themes"] --> C["Parse into focus group questions"] --> D["POST /api/v1/focus-groups"] --> E["Deep qualitative insight per theme"]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, json, requests, time
  from openai import OpenAI

  TF = os.environ["TYPEFORM_TOKEN"]
  MV = os.environ["MAVERA_API_KEY"]
  TF_BASE = "https://api.typeform.com"
  MB = "https://app.mavera.io/api/v1"
  TF_H = {"Authorization": f"Bearer {TF}"}
  MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  FORM_ID = os.environ.get("TYPEFORM_FORM_ID", "your_form_id")
  PERSONA_IDS = os.environ.get("PERSONA_IDS", "").split(",")

  # 1. Get form and identify open-ended fields
  form = requests.get(f"{TF_BASE}/forms/{FORM_ID}", headers=TF_H).json()
  open_fields = [f for f in form.get("fields", [])
                 if f.get("type") in ("long_text", "short_text")]
  print(f"Open-ended fields: {len(open_fields)}")

  # 2. Pull responses
  responses = []
  params = {"page_size": 1000}
  while True:
      r = requests.get(f"{TF_BASE}/forms/{FORM_ID}/responses",
          headers=TF_H, params=params)
      if r.status_code == 429:
          time.sleep(1)
      else:
          r.raise_for_status()
          data = r.json()
          responses.extend(data.get("items", []))
          if len(data.get("items", [])) < 1000:
              break
          params["before"] = data["items"][-1]["token"]
      time.sleep(0.6)

  # 3. Extract open-ended answers grouped by field
  open_field_ids = {f["id"] for f in open_fields}
  field_titles = {f["id"]: f.get("title", f["id"]) for f in open_fields}
  answers_by_field = {fid: [] for fid in open_field_ids}

  for resp in responses:
      for ans in resp.get("answers", []):
          fid = ans.get("field", {}).get("id", "")
          if fid in open_field_ids and ans.get("type") == "text":
              text = ans.get("text", "").strip()
              if text and len(text) > 10:
                  answers_by_field[fid].append(text)

  # 4. Identify themes with Mavera Chat
  mavera = OpenAI(api_key=MV, base_url=MB)

  all_text = []
  for fid, answers in answers_by_field.items():
      title = field_titles[fid]
      for ans in answers[:50]:
          all_text.append(f"[{title}] {ans[:200]}")

  theme_result = mavera.responses.create(
      model="mavera-1",
      input=[{"role": "user", "content": f"""Analyze these {len(all_text)} open-ended survey responses.

  Identify 5-7 distinct themes. For each theme:
  - Theme name (2-4 words)
  - Frequency estimate (what % of responses mention it)
  - Representative quotes (3 examples)
  - Underlying sentiment (positive, negative, mixed)
  - Key insight

  RESPONSES:
  {chr(10).join(all_text[:100])}

  Return as JSON: {{"themes": [...]}}"""}],
  )

  theme_content = theme_result.output[0].content[0].text
  print("=== Discovered Themes ===")
  print(theme_content[:1500])

  # 5. Parse themes and create focus group questions
  try:
      json_str = theme_content[theme_content.find("{"):theme_content.rfind("}")+1]
      themes = json.loads(json_str).get("themes", [])
  except (json.JSONDecodeError, ValueError):
      themes = []

  focus_questions = []
  for theme in themes[:5]:
      name = theme.get("name", "Unknown")
      sentiment = theme.get("sentiment", "mixed")
      quotes = theme.get("representative_quotes", theme.get("quotes", []))
      quote_sample = quotes[0] if quotes else "N/A"

      focus_questions.append(
          f'Survey respondents mentioned "{name}" — e.g., "{quote_sample[:100]}". '
          f"How does this resonate with your experience? What would you add?"
      )

  focus_questions.append("Which of these themes matters most to you? Why?")
  focus_questions.append("What's missing from these themes? What topic should we have asked about?")

  # 6. Run Focus Group
  if not PERSONA_IDS or PERSONA_IDS == [""]:
      p = requests.post(f"{MB}/personas", headers=MV_H, json={
          "name": "TF Survey Respondent",
          "description": "Represents the typical respondent of this Typeform survey.",
      }).json()
      PERSONA_IDS = [p["id"]]

  fg = requests.post(f"{MB}/focus-groups", headers=MV_H, json={
      "name": f"Theme Deep-Dive: {form.get('title', 'Survey')}",
      "persona_ids": PERSONA_IDS,
      "questions": focus_questions,
      "context": f"Based on analysis of {len(responses)} survey responses. Themes discovered: {', '.join(t.get('name','') for t in themes[:5])}",
      "responses_per_persona": 3,
  }).json()

  # 7. Poll for results
  for _ in range(20):
      time.sleep(5)
      result = requests.get(f"{MB}/focus-groups/{fg['id']}",
          headers=MV_H).json()
      if result.get("status") == "completed":
          break

  print(f"\nFocus Group: {fg['id']}")
  for resp in result.get("responses", []):
      print(f"\n[{resp.get('persona_name', '?')}] {resp.get('question', '')[:70]}...")
      print(f"  → {resp.get('answer', '')[:250]}")
  ```

  ```javascript JavaScript theme={"dark"}
  import OpenAI from "openai";

  const TF = process.env.TYPEFORM_TOKEN;
  const MV = process.env.MAVERA_API_KEY;
  const TF_BASE = "https://api.typeform.com";
  const MB = "https://app.mavera.io/api/v1";
  const tfH = { Authorization: `Bearer ${TF}` };
  const mvH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
  const FORM_ID = process.env.TYPEFORM_FORM_ID || "your_form_id";
  let personaIds = (process.env.PERSONA_IDS || "").split(",").filter(Boolean);

  // 1. Form structure
  const form = await fetch(`${TF_BASE}/forms/${FORM_ID}`, { headers: tfH }).then(r => r.json());
  const openFields = (form.fields || []).filter(f => ["long_text", "short_text"].includes(f.type));
  const fieldTitles = Object.fromEntries(openFields.map(f => [f.id, f.title || f.id]));

  // 2. Pull responses
  const responses = [];
  const params = new URLSearchParams({ page_size: "1000" });
  while (true) {
    let res = await fetch(`${TF_BASE}/forms/${FORM_ID}/responses?${params}`, { headers: tfH });
    if (res.status === 429) { await new Promise(r => setTimeout(r, 1000)); continue; }
    const data = await res.json();
    responses.push(...(data.items || []));
    if ((data.items || []).length < 1000) break;
    params.set("before", data.items[data.items.length - 1].token);
    await new Promise(r => setTimeout(r, 600));
  }

  // 3. Extract open-ended
  const openFieldIds = new Set(openFields.map(f => f.id));
  const answersByField = {};
  for (const fid of openFieldIds) answersByField[fid] = [];
  for (const resp of responses) {
    for (const ans of resp.answers || []) {
      const fid = ans.field?.id;
      if (openFieldIds.has(fid) && ans.type === "text" && (ans.text || "").trim().length > 10)
        answersByField[fid].push(ans.text.trim());
    }
  }

  const allText = [];
  for (const [fid, answers] of Object.entries(answersByField)) {
    for (const ans of answers.slice(0, 50))
      allText.push(`[${fieldTitles[fid] || fid}] ${ans.slice(0, 200)}`);
  }

  // 4. Theme identification
  const mavera = new OpenAI({ apiKey: MV, baseURL: MB });
  const themeResult = await mavera.responses.create({
    model: "mavera-1",
    input: [{ role: "user", content: `Analyze these ${allText.length} open-ended responses.
  Identify 5-7 themes. For each: name, frequency %, representative quotes (3), sentiment, key insight.
  Return JSON: {"themes": [...]}

  RESPONSES:
  ${allText.slice(0, 100).join("\n")}` }],
  });

  const themeContent = themeResult.output[0].content[0].text;
  console.log("=== Discovered Themes ===");
  console.log(themeContent.slice(0, 1500));

  // 5. Parse themes
  let themes = [];
  try {
    const jsonStr = themeContent.slice(themeContent.indexOf("{"), themeContent.lastIndexOf("}") + 1);
    themes = JSON.parse(jsonStr).themes || [];
  } catch { themes = []; }

  const focusQuestions = themes.slice(0, 5).map(t => {
    const quote = (t.representative_quotes || t.quotes || ["N/A"])[0];
    return `Respondents mentioned "${t.name}" — e.g., "${(quote || "").slice(0, 100)}". How does this resonate? What would you add?`;
  });
  focusQuestions.push("Which theme matters most to you? Why?");
  focusQuestions.push("What's missing? What topic should we have asked about?");

  // 6. Ensure personas
  if (!personaIds.length) {
    const p = await fetch(`${MB}/personas`, { method: "POST", headers: mvH,
      body: JSON.stringify({ name: "TF Survey Respondent",
        description: "Typical respondent of this Typeform survey." }),
    }).then(r => r.json());
    personaIds = [p.id];
  }

  // 7. Focus Group
  const fg = await fetch(`${MB}/focus-groups`, { method: "POST", headers: mvH,
    body: JSON.stringify({
      name: `Theme Deep-Dive: ${form.title}`,
      persona_ids: personaIds, questions: focusQuestions,
      context: `Based on ${responses.length} responses. Themes: ${themes.slice(0, 5).map(t => t.name).join(", ")}`,
      responses_per_persona: 3,
    }),
  }).then(r => r.json());

  let result;
  for (let i = 0; i < 20; i++) {
    await new Promise(r => setTimeout(r, 5000));
    result = await fetch(`${MB}/focus-groups/${fg.id}`, { headers: mvH }).then(r => r.json());
    if (result.status === "completed") break;
  }

  console.log(`\nFocus Group: ${fg.id}`);
  for (const resp of result.responses || []) {
    console.log(`\n[${resp.persona_name || "?"}] ${(resp.question || "").slice(0, 70)}...`);
    console.log(`  → ${(resp.answer || "").slice(0, 250)}`);
  }
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
=== Discovered Themes ===
1. "Tool Consolidation" (42%) — "I use 7 different tools and none talk to each other"
2. "Time to Value" (38%) — "We spent 3 months onboarding our last platform"
3. "Pricing Transparency" (31%) — "Hidden fees killed our budget mid-year"
4. "Team Adoption" (27%) — "I love it but my team won't switch from spreadsheets"
5. "Data Security" (19%) — "SOC 2 is table stakes, we need more"

Focus Group: fg_tf_themes_4k

[Growth-Stage Operator] Respondents mentioned "Tool Consolidation"...
  → Absolutely. The cognitive overhead of context-switching between 7 tools
    is worse than any single tool's limitations. What I'd add: it's not just
    about features — it's about having one place to think.

[Enterprise Evaluator] Which theme matters most to you? Why?
  → Data Security, hands down. Tool consolidation is a nice-to-have, but
    a security incident is existential. I'd also add "Vendor Risk Assessment
    Burden" — every new tool means another 6-week security review.
```

### Error Handling

<AccordionGroup>
  <Accordion title="Short responses add noise">Responses under 10 characters (e.g., "N/A", "none") are filtered out. Adjust the threshold based on your survey's typical response quality.</Accordion>
  <Accordion title="Theme count depends on response volume">With fewer than 50 open-ended responses, Mave may only find 2-3 themes. The code handles variable theme counts gracefully.</Accordion>
  <Accordion title="Focus Group question length">Questions derived from themes can be long. Keep the quote excerpt under 100 characters to prevent focus group prompt overflow.</Accordion>
</AccordionGroup>
