byteastra / scripts /generate_qa_pairs.py
risu1012's picture
feat: optimize deployment payload using zipped database index
9fca47f
Raw
History Blame Contribute Delete
9.91 kB
"""
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."
)
# ── Template-based QA patterns ─────────────────────────────────────────────────
QUESTION_TEMPLATES = [
# Definition questions
("What is {concept}?", "definition"),
("Define {concept} in Ayurveda.", "definition"),
("What does the term '{concept}' mean in Ayurvedic medicine?", "definition"),
# Explain questions
("Explain {concept} according to Ayurveda.", "explanation"),
("Describe the concept of {concept} as per classical texts.", "explanation"),
("How is {concept} described in Ayurvedic texts?", "explanation"),
# Function/significance questions
("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"),
# List questions
("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"),
# Clinical questions
("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"),
# Relationship questions
("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"),
]
# Key Ayurvedic concepts to extract and generate questions around
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
# Pick the top paragraph(s) up to window characters
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
# Generate up to 3 questions per concept per file
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")
# Pick the first 800 chars as context for overview question
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))
# Deduplicate by question
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)
# Write JSONL
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()