| """ |
| ByteAstra — QA Pair Generator for Fine-Tuning Dataset |
| |
| Reads scraped Ayurveda markdown files and generates instruction-tuning |
| question-answer pairs in ShareGPT format. |
| |
| Two modes: |
| 1. --template-mode : Rule-based QA extraction (fast, no GPU needed) |
| 2. --llm-mode : Uses a local LLM to generate diverse QA pairs (slower, richer) |
| |
| Usage: |
| # Fast template mode (recommended for initial dataset): |
| python scripts/generate_qa_pairs.py \ |
| --input-dir ./data/ayurveda/scraped \ |
| --output ./data/finetune/ayurveda_train.jsonl \ |
| --template-mode |
| |
| Output: |
| JSONL file where each line is a JSON conversation object: |
| { |
| "conversations": [ |
| {"role": "system", "content": "..."}, |
| {"role": "user", "content": "...question..."}, |
| {"role": "assistant", "content": "...answer...\n\nSources: Charaka Samhita, Ch. 1"} |
| ], |
| "metadata": {"source": "...", "chunk_id": "..."} |
| } |
| """ |
| from __future__ import annotations |
| import argparse |
| import hashlib |
| import json |
| import logging |
| import re |
| import sys |
| from pathlib import Path |
|
|
| import yaml |
|
|
| logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s") |
| logger = logging.getLogger(__name__) |
|
|
| SYSTEM_PROMPT = ( |
| "You are ByteAstra, an expert BAMS (Bachelor of Ayurvedic Medicine and Surgery) tutor. " |
| "You answer questions about Ayurveda based strictly on classical texts and BAMS curriculum. " |
| "Always cite the source textbook and chapter at the end of your answer. " |
| "If you do not know the answer from the provided context, say so clearly." |
| ) |
|
|
| |
|
|
| QUESTION_TEMPLATES = [ |
| |
| ("What is {concept}?", "definition"), |
| ("Define {concept} in Ayurveda.", "definition"), |
| ("What does the term '{concept}' mean in Ayurvedic medicine?", "definition"), |
|
|
| |
| ("Explain {concept} according to Ayurveda.", "explanation"), |
| ("Describe the concept of {concept} as per classical texts.", "explanation"), |
| ("How is {concept} described in Ayurvedic texts?", "explanation"), |
|
|
| |
| ("What is the significance of {concept} in maintaining health?", "significance"), |
| ("What are the functions of {concept} in the body?", "significance"), |
| ("How does {concept} contribute to overall health?", "significance"), |
|
|
| |
| ("What are the types of {concept}?", "list"), |
| ("List the subtypes of {concept} with their characteristics.", "list"), |
| ("How many types of {concept} are described in Ayurveda?", "list"), |
|
|
| |
| ("What are the signs of {concept} imbalance?", "clinical"), |
| ("How does disturbance in {concept} manifest as disease?", "clinical"), |
| ("What happens when {concept} is aggravated or depleted?", "clinical"), |
|
|
| |
| ("How is {concept} related to the Doshas?", "relationship"), |
| ("What is the relationship between {concept} and digestion?", "relationship"), |
| ("How do {concept} and Agni interact in Ayurveda?", "relationship"), |
| ] |
|
|
| |
| AYURVEDA_CONCEPTS = [ |
| "Vata", "Pitta", "Kapha", "Tridosha", "Dosha", |
| "Agni", "Jatharagni", "Dhatvagni", "Bhutagni", |
| "Rasa Dhatu", "Rakta Dhatu", "Mamsa Dhatu", "Meda Dhatu", |
| "Asthi Dhatu", "Majja Dhatu", "Shukra Dhatu", "Sapta Dhatu", |
| "Ojas", "Ama", "Srotas", "Prakriti", "Vikriti", |
| "Panchamahabhutas", "Akasha", "Vayu", "Teja", "Jala", "Prithvi", |
| "Dinacharya", "Ritucharya", "Ahara", "Vihara", |
| "Nidana", "Samprapti", "Chikitsa", "Aushadha", |
| "Vata Prakriti", "Pitta Prakriti", "Kapha Prakriti", |
| "Sama Agni", "Vishama Agni", "Tikshna Agni", "Manda Agni", |
| ] |
|
|
|
|
| def parse_frontmatter(text: str) -> tuple[dict, str]: |
| meta: dict = {} |
| if text.startswith("---"): |
| lines = text.splitlines() |
| end_idx = None |
| for i, line in enumerate(lines[1:], 1): |
| if line.strip() == "---": |
| end_idx = i |
| break |
| if end_idx: |
| try: |
| meta = yaml.safe_load("\n".join(lines[1:end_idx])) or {} |
| except Exception: |
| pass |
| text = "\n".join(lines[end_idx + 1:]).strip() |
| return meta, text |
|
|
|
|
| def extract_relevant_section(body: str, concept: str, window: int = 600) -> str | None: |
| """Find the most relevant paragraph(s) mentioning the concept.""" |
| concept_lower = concept.lower() |
| paragraphs = [p.strip() for p in re.split(r"\n{2,}", body) if len(p.strip()) > 50] |
|
|
| scored = [] |
| for para in paragraphs: |
| score = para.lower().count(concept_lower) |
| if score > 0: |
| scored.append((score, para)) |
|
|
| if not scored: |
| return None |
|
|
| |
| scored.sort(reverse=True) |
| result = [] |
| total = 0 |
| for _, para in scored[:3]: |
| if total + len(para) <= window: |
| result.append(para) |
| total += len(para) |
|
|
| return "\n\n".join(result) if result else None |
|
|
|
|
| def build_qa_pair( |
| question: str, |
| answer_context: str, |
| meta: dict, |
| chunk_id: str, |
| ) -> dict: |
| source = meta.get("source", "Ayurvedic Classical Texts") |
| chapter = meta.get("chapter", "") |
| url = meta.get("url", "") |
|
|
| citation = f"\n\nSources: {source}" |
| if chapter: |
| citation += f", {chapter}" |
|
|
| full_answer = answer_context.strip() + citation |
|
|
| return { |
| "conversations": [ |
| {"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": question}, |
| {"role": "assistant", "content": full_answer}, |
| ], |
| "metadata": { |
| "source": source, |
| "chapter": chapter, |
| "chunk_id": chunk_id, |
| "url": url, |
| }, |
| } |
|
|
|
|
| def generate_pairs_for_file(file_path: Path) -> list[dict]: |
| text = file_path.read_text(encoding="utf-8", errors="ignore") |
| meta, body = parse_frontmatter(text) |
|
|
| pairs = [] |
| seen_questions: set[str] = set() |
|
|
| for concept in AYURVEDA_CONCEPTS: |
| context = extract_relevant_section(body, concept) |
| if not context or len(context) < 100: |
| continue |
|
|
| |
| import random |
| templates = random.sample(QUESTION_TEMPLATES, min(3, len(QUESTION_TEMPLATES))) |
| for template, _ in templates: |
| question = template.format(concept=concept) |
| if question in seen_questions: |
| continue |
| seen_questions.add(question) |
|
|
| chunk_id = hashlib.md5(f"{file_path.name}:{concept}:{question}".encode()).hexdigest()[:12] |
| pair = build_qa_pair(question, context, meta, chunk_id) |
| pairs.append(pair) |
|
|
| return pairs |
|
|
|
|
| def generate_pairs_from_chapters(body: str, meta: dict, file_path: Path) -> list[dict]: |
| """Also generate 'full chapter' QA pairs — overview questions about the whole chapter.""" |
| chapter = meta.get("chapter", file_path.stem) |
| source = meta.get("source", "Ayurvedic Classical Texts") |
|
|
| |
| overview_context = body[:800].strip() |
| if len(overview_context) < 100: |
| return [] |
|
|
| overview_questions = [ |
| f"What is covered in the chapter '{chapter}' of {source}?", |
| f"Summarize the key concepts discussed in {chapter}.", |
| f"What are the main teachings of {chapter} according to {source}?", |
| ] |
|
|
| pairs = [] |
| for q in overview_questions[:2]: |
| chunk_id = hashlib.md5(f"{file_path.name}:overview:{q}".encode()).hexdigest()[:12] |
| pair = build_qa_pair(q, overview_context, meta, chunk_id) |
| pairs.append(pair) |
|
|
| return pairs |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Generate QA pairs from scraped Ayurveda texts") |
| parser.add_argument("--input-dir", required=True, type=Path) |
| parser.add_argument("--output", required=True, type=Path) |
| parser.add_argument("--template-mode", action="store_true", default=True, |
| help="Use rule-based QA generation (no GPU needed)") |
| parser.add_argument("--max-files", type=int, default=None, help="Limit number of files") |
| args = parser.parse_args() |
|
|
| files = list(args.input_dir.rglob("**/*.md")) + list(args.input_dir.rglob("**/*.txt")) |
| if args.max_files: |
| files = files[:args.max_files] |
|
|
| if not files: |
| logger.error("No .md/.txt files found in %s", args.input_dir) |
| sys.exit(1) |
|
|
| logger.info("Processing %d files...", len(files)) |
| args.output.parent.mkdir(parents=True, exist_ok=True) |
|
|
| all_pairs: list[dict] = [] |
| for i, file_path in enumerate(files, 1): |
| logger.info("[%d/%d] %s", i, len(files), file_path.name) |
| text = file_path.read_text(encoding="utf-8", errors="ignore") |
| meta, body = parse_frontmatter(text) |
|
|
| pairs = generate_pairs_for_file(file_path) |
| pairs += generate_pairs_from_chapters(body, meta, file_path) |
| all_pairs.extend(pairs) |
| logger.info(" → %d QA pairs", len(pairs)) |
|
|
| |
| seen: set[str] = set() |
| unique_pairs = [] |
| for pair in all_pairs: |
| q = pair["conversations"][1]["content"] |
| if q not in seen: |
| seen.add(q) |
| unique_pairs.append(pair) |
|
|
| |
| with open(args.output, "w", encoding="utf-8") as f: |
| for pair in unique_pairs: |
| f.write(json.dumps(pair, ensure_ascii=False) + "\n") |
|
|
| logger.info("✓ Done! %d unique QA pairs written to %s", len(unique_pairs), args.output) |
| logger.info("Next step: python finetune/train.py --dataset %s", args.output) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|