| """ |
| scripts/04_generate_synth_dialogues.py |
| Generate synthetic (pictogram_sequence → target_utterance) training pairs |
| for each persona using Gemini Pro or Claude API. |
| |
| Usage: |
| python scripts/04_generate_synth_dialogues.py --api gemini --persona arjun |
| python scripts/04_generate_synth_dialogues.py --api claude --all |
| """ |
|
|
| import argparse |
| import json |
| import logging |
| import os |
| import random |
| import time |
| from pathlib import Path |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| log = logging.getLogger(__name__) |
|
|
| SYNTH_DIR = Path("data/synth") |
| TARGET_PER_PERSONA = 3000 |
| BATCH_SIZE = 50 |
|
|
| |
| CONTEXTS = [ |
| "breakfast", "lunch", "dinner", "school", "bedtime", "morning", |
| "physiotherapy", "playing", "watching TV", "visiting temple/church/mosque", |
| "shopping with family", "bath time", "doctor visit", "talking to grandparent", |
| ] |
|
|
| |
| PICTOGRAM_CATEGORIES = { |
| "people": ["mother", "father", "sister", "brother", "teacher", "doctor", "friend"], |
| "food": ["water", "milk", "rice", "bread", "fruit", "medicine", "biscuit"], |
| "actions": ["want", "give", "go", "come", "stop", "help", "eat", "drink", "sleep", "play"], |
| "feelings": ["happy", "sad", "pain", "tired", "scared", "angry", "love"], |
| "objects": ["tablet", "phone", "TV", "book", "toy", "cup", "toilet", "bed", "school"], |
| "places": ["home", "school", "hospital", "outside", "temple", "market"], |
| "time": ["now", "later", "today", "morning", "night"], |
| "modifiers": ["more", "no", "yes", "big", "hot", "cold", "mine"], |
| } |
|
|
|
|
| def sample_pictogram_sequence(length: int = None) -> list[str]: |
| """Generate a realistic pictogram tap sequence of 1-4 symbols.""" |
| if length is None: |
| length = random.choices([1, 2, 3, 4], weights=[0.1, 0.35, 0.4, 0.15])[0] |
| sequence = [] |
| cats = list(PICTOGRAM_CATEGORIES.keys()) |
| for _ in range(length): |
| cat = random.choice(cats) |
| symbol = random.choice(PICTOGRAM_CATEGORIES[cat]) |
| sequence.append(symbol) |
| return list(dict.fromkeys(sequence)) |
|
|
|
|
| def build_gemini_prompt(persona_dict: dict, context: str, sequences: list[list[str]]) -> str: |
| """Build the prompt for Gemini to generate utterances.""" |
| lang_str = f"{persona_dict['primary_language']} (primary), {', '.join(persona_dict['secondary_languages'])}" |
| sequences_str = "\n".join( |
| f"{i+1}. [{', '.join(seq)}]" for i, seq in enumerate(sequences) |
| ) |
| return f"""You are generating training data for an AAC (Augmentative & Alternative Communication) app for children with cerebral palsy in India. |
| |
| Child profile: |
| - Name: {persona_dict['name']}, Age: {persona_dict['age']} |
| - City: {persona_dict['city']}, {persona_dict['state']} |
| - CP type: {persona_dict['cp_type']} ({persona_dict['severity']}) |
| - Languages: {lang_str} |
| - Code-switching: {persona_dict['code_switch_pattern']} |
| - Favourite foods: {', '.join(persona_dict['favourite_foods'])} |
| - Favourite activities: {', '.join(persona_dict['favourite_activities'])} |
| |
| Context: {context} |
| |
| For each pictogram sequence below, generate the MOST LIKELY natural sentence this child wants to communicate. Rules: |
| 1. Use the child's actual language mix (NOT just English) |
| 2. Keep sentences short (5-15 words), age-appropriate |
| 3. Be specific to the child's life (use their favourite foods/activities when relevant) |
| 4. Also generate 2 ALTERNATIVE sentences (plausible but less likely) |
| 5. Also generate 1 NEGATIVE example (a sentence that would be WRONG to output — e.g. inappropriate for a child, or completely off-context) |
| 6. Output ONLY valid JSON, no preamble |
| |
| Pictogram sequences: |
| {sequences_str} |
| |
| Output format (one JSON object per line, {len(sequences)} lines total): |
| {{"seq": ["symbol1", "symbol2"], "target": "primary sentence", "alternatives": ["alt1", "alt2"], "negative": "wrong sentence", "context": "{context}"}}""" |
|
|
|
|
| def call_gemini(prompt: str, model: str = "gemini-1.5-pro") -> str: |
| import google.generativeai as genai |
| api_key = os.environ.get("GOOGLE_API_KEY") |
| if not api_key: |
| raise EnvironmentError("GOOGLE_API_KEY not set") |
| genai.configure(api_key=api_key) |
| m = genai.GenerativeModel(model) |
| response = m.generate_content(prompt) |
| return response.text |
|
|
|
|
| def call_claude(prompt: str, model: str = "claude-opus-4-6") -> str: |
| import anthropic |
| api_key = os.environ.get("ANTHROPIC_API_KEY") |
| if not api_key: |
| raise EnvironmentError("ANTHROPIC_API_KEY not set") |
| client = anthropic.Anthropic(api_key=api_key) |
| msg = client.messages.create( |
| model=model, |
| max_tokens=4096, |
| messages=[{"role": "user", "content": prompt}] |
| ) |
| return msg.content[0].text |
|
|
|
|
| def parse_api_response(text: str) -> list[dict]: |
| """Parse newline-delimited JSON from the API response.""" |
| records = [] |
| |
| text = text.strip() |
| if text.startswith("```"): |
| text = text.split("\n", 1)[-1].rsplit("```", 1)[0] |
|
|
| for line in text.strip().split("\n"): |
| line = line.strip() |
| if not line or not line.startswith("{"): |
| continue |
| try: |
| obj = json.loads(line) |
| if "target" in obj and "seq" in obj: |
| records.append(obj) |
| except json.JSONDecodeError: |
| continue |
| return records |
|
|
|
|
| def generate_for_persona( |
| persona_id: str, |
| api: str = "gemini", |
| target: int = TARGET_PER_PERSONA, |
| ) -> list[dict]: |
| import sys |
| sys.path.insert(0, "src") |
| from ankahi.data.persona import PERSONAS |
|
|
| persona = PERSONAS[persona_id] |
| persona_dict = persona.to_dict() |
|
|
| out_path = SYNTH_DIR / f"persona_{persona_id}.jsonl" |
| SYNTH_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| |
| existing = [] |
| if out_path.exists(): |
| with open(out_path) as f: |
| existing = [json.loads(l) for l in f if l.strip()] |
| log.info(f"Resuming: {len(existing)} records already in {out_path}") |
|
|
| all_records = existing[:] |
| needed = target - len(all_records) |
|
|
| if needed <= 0: |
| log.info(f"Persona {persona_id} already has {len(all_records)} records. Skipping.") |
| return all_records |
|
|
| log.info(f"Generating {needed} records for persona: {persona.name}") |
|
|
| call_fn = call_gemini if api == "gemini" else call_claude |
| contexts = CONTEXTS * ((needed // (len(CONTEXTS) * BATCH_SIZE)) + 2) |
|
|
| batch_num = 0 |
| with open(out_path, "a", encoding="utf-8") as f: |
| while len(all_records) - len(existing) < needed: |
| context = contexts[batch_num % len(contexts)] |
| batch_seqs = [sample_pictogram_sequence() for _ in range(BATCH_SIZE)] |
| prompt = build_gemini_prompt(persona_dict, context, batch_seqs) |
|
|
| try: |
| raw = call_fn(prompt) |
| records = parse_api_response(raw) |
| for r in records: |
| r["persona_id"] = persona_id |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") |
| all_records.extend(records) |
| log.info(f" Batch {batch_num}: got {len(records)} records (total {len(all_records)})") |
| except Exception as e: |
| log.error(f" API call failed (batch {batch_num}): {e}") |
| time.sleep(5) |
|
|
| batch_num += 1 |
| time.sleep(1.5) |
|
|
| log.info(f"Done: {len(all_records)} records for {persona.name}") |
| return all_records |
|
|
|
|
| def generate_shared_base(api: str = "gemini", n: int = 1500) -> list[dict]: |
| """Generate a shared base corpus not tied to any persona.""" |
| out_path = SYNTH_DIR / "base_shared.jsonl" |
| if out_path.exists(): |
| with open(out_path) as f: |
| existing = [json.loads(l) for l in f if l.strip()] |
| if len(existing) >= n: |
| log.info(f"Base corpus already done ({len(existing)} records).") |
| return existing |
|
|
| |
| generic_persona = { |
| "name": "child", "age": 8, "city": "India", "state": "India", |
| "cp_type": "spastic", "severity": "moderate", |
| "primary_language": "hi", "secondary_languages": ["en"], |
| "code_switch_pattern": "Hindi base with English nouns", |
| "favourite_foods": ["rice", "dal", "roti"], "favourite_activities": ["playing", "TV"], |
| } |
| call_fn = call_gemini if api == "gemini" else call_claude |
| records = [] |
| with open(out_path, "w", encoding="utf-8") as f: |
| while len(records) < n: |
| context = random.choice(CONTEXTS) |
| seqs = [sample_pictogram_sequence() for _ in range(BATCH_SIZE)] |
| prompt = build_gemini_prompt(generic_persona, context, seqs) |
| try: |
| raw = call_fn(prompt) |
| batch = parse_api_response(raw) |
| for r in batch: |
| r["persona_id"] = "base" |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") |
| records.extend(batch) |
| log.info(f" Base: {len(records)}/{n}") |
| except Exception as e: |
| log.error(f" Base batch error: {e}") |
| time.sleep(5) |
| time.sleep(1.5) |
| return records |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--api", choices=["gemini", "claude"], default="gemini") |
| parser.add_argument("--persona", type=str, default=None, |
| help="Single persona ID (ananya/arjun/priya/rohan/zara)") |
| parser.add_argument("--all", action="store_true", help="Generate for all personas + base") |
| parser.add_argument("--base-only", action="store_true") |
| parser.add_argument("--target", type=int, default=TARGET_PER_PERSONA) |
| args = parser.parse_args() |
|
|
| if args.base_only: |
| generate_shared_base(args.api) |
| return |
|
|
| if args.all: |
| generate_shared_base(args.api) |
| for pid in ["ananya", "arjun", "priya", "rohan", "zara"]: |
| generate_for_persona(pid, args.api, args.target) |
| elif args.persona: |
| generate_for_persona(args.persona, args.api, args.target) |
| else: |
| parser.print_help() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|