ankahi / scripts /04_generate_synth_dialogues.py
bhriguverma's picture
Add files using upload-large-folder tool
dc7e099 verified
Raw
History Blame Contribute Delete
10.3 kB
"""
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 # utterances per API call
# Meal and time contexts to diversify examples
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",
]
# AAC pictogram categories for sequences
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)) # deduplicate while preserving order
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 = []
# Strip markdown fences if present
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)
# Resume if partially done
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) # rate limiting
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
# Use a generic Indian child persona
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()