eduai / core /structured_output.py
Shreesha-2011's picture
Deploy EduAI backend - LLM + TTS + RAG + 15 maths chapters
1d9dc1f
Raw
History Blame Contribute Delete
8.3 kB
import json
import re
from datetime import datetime
from pathlib import Path
def parse_quiz(raw_text):
questions = []
# split on question markers like Q1:, Q2:, 1., 2. etc
question_blocks = re.split(
r"(?:^|\n)\s*(?:Q\d*[\.:]\s*|(?:\d+)[\.)\s]+)",
raw_text.strip(),
)
# fallback: split on blank lines
if len(question_blocks) <= 1:
question_blocks = re.split(r"\n\s*\n", raw_text.strip())
for block in question_blocks:
block = block.strip()
if not block:
continue
question = _parse_single_question(block)
if question:
questions.append(question)
return questions
def _parse_single_question(block):
lines = block.strip().split("\n")
if not lines:
return None
question_text = ""
option_lines = []
answer_line = ""
for line in lines:
stripped = line.strip()
if not stripped:
continue
# option line like A), B), etc
if re.match(r"^[A-Da-d][).:\s]", stripped):
option_lines.append(stripped)
# answer line
elif re.match(r"^(?:Answer|Correct|Ans)[:\s]", stripped, re.IGNORECASE):
answer_line = stripped
# question text (before any options or answer)
elif not option_lines and not answer_line:
if question_text:
question_text += " " + stripped
else:
question_text = stripped
if not question_text:
return None
options = []
for opt in option_lines:
clean_opt = re.sub(r"^[A-Da-d][).:\s]+", "", opt).strip()
options.append(clean_opt)
correct_answer = ""
if answer_line:
match = re.search(r"[A-Da-d]", answer_line)
if match:
correct_answer = match.group().upper()
return {
"question": question_text,
"options": options,
"correct_answer": correct_answer,
}
def format_quiz(questions):
if not questions:
return ""
lines = ["", " ╔══════════════════════════════════════════╗"]
lines.append(" β•‘ πŸ“ QUIZ β•‘")
lines.append(" β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
lines.append("")
option_letters = ["A", "B", "C", "D"]
for index, question in enumerate(questions, start=1):
lines.append(f" Q{index}: {question['question']}")
if question["options"]:
for opt_index, option in enumerate(question["options"]):
letter = option_letters[opt_index] if opt_index < 4 else f"{opt_index + 1}"
lines.append(f" {letter}) {option}")
if question["correct_answer"]:
lines.append(f" βœ… Answer: {question['correct_answer']}")
lines.append("")
lines.append(" " + "─" * 44)
return "\n".join(lines)
def parse_flashcards(raw_text):
# Try to parse complete JSON array first
try:
start = raw_text.find("[")
end = raw_text.rfind("]")
if start != -1 and end != -1 and end > start:
json_str = raw_text[start:end+1]
data = json.loads(json_str)
if isinstance(data, list):
cards = []
for item in data:
if isinstance(item, dict) and "front" in item and "back" in item:
card = _clean_card(str(item["front"]), str(item["back"]))
if card:
cards.append(card)
if cards:
return cards
except Exception:
pass
# Recover truncated JSON β€” LLM may have run out of tokens mid-card
try:
start = raw_text.find("[")
if start != -1:
fragment = raw_text[start:]
# find the last complete card object (last "}")
last_brace = fragment.rfind("}")
if last_brace != -1:
recovered = fragment[:last_brace + 1] + "]"
data = json.loads(recovered)
if isinstance(data, list):
cards = []
for item in data:
if isinstance(item, dict) and "front" in item and "back" in item:
card = _clean_card(str(item["front"]), str(item["back"]))
if card:
cards.append(card)
if cards:
return cards
except Exception:
pass
cards = []
# try Front: / Back: pattern first
front_back_pairs = re.findall(
r"(?:Front|Question|Q)\s*[:\-]\s*(.+?)(?:\n|\s{2,})(?:Back|Answer|A)\s*[:\-]\s*(.+?)(?=\n\s*(?:Front|Question|Q)\s*[:\-]|\Z)",
raw_text,
re.IGNORECASE | re.DOTALL,
)
if front_back_pairs:
for front, back in front_back_pairs:
card = _clean_card(front.strip(), back.strip())
if card:
cards.append(card)
return cards
# try numbered pairs like "1. term - definition"
numbered_pairs = re.findall(
r"\d+[.)]\s*(.+?)\s*[-–→:]\s*(.+?)(?=\n\s*\d+[.)]|\Z)",
raw_text,
re.DOTALL,
)
if numbered_pairs:
for front, back in numbered_pairs:
card = _clean_card(front.strip(), back.strip())
if card:
cards.append(card)
return cards
# try bullet points like "- Term: Definition"
term_defs = re.findall(
r"[-β€’*]\s*\**(.+?)\**\s*[:\-–]\s*(.+)",
raw_text,
)
if term_defs:
for front, back in term_defs:
card = _clean_card(front.strip(), back.strip())
if card:
cards.append(card)
return cards
return cards
def _clean_card(front, back):
front = front.strip().rstrip(":")
back = back.strip()
if not front or not back:
return None
if len(front) < 2 or len(back) < 2:
return None
return {"front": front, "back": back}
def format_flashcards(cards):
if not cards:
return ""
lines = ["", " ╔══════════════════════════════════════════╗"]
lines.append(" β•‘ πŸƒ FLASHCARDS β•‘")
lines.append(" β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")
lines.append("")
for index, card in enumerate(cards, start=1):
lines.append(f" Card {index}")
lines.append(f" Front: {card['front']}")
lines.append(f" Back: {card['back']}")
lines.append("")
lines.append(" " + "─" * 44)
return "\n".join(lines)
def save_progress(data_dir, mode, structured_data, user_prompt):
if not structured_data:
return
progress_dir = Path(data_dir) / "progress"
progress_dir.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"mode": mode,
"prompt": user_prompt[:200],
"item_count": len(structured_data),
"items": structured_data,
}
file_name = f"{mode}_history.jsonl"
file_path = progress_dir / file_name
try:
with file_path.open("a", encoding="utf-8") as progress_file:
progress_file.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError:
pass
def process_structured_output(raw_text, mode, data_dir, user_prompt):
if mode == "quiz":
questions = parse_quiz(raw_text)
if questions:
formatted = format_quiz(questions)
save_progress(data_dir, mode, questions, user_prompt)
return formatted, questions
return raw_text, []
if mode == "flashcard":
cards = parse_flashcards(raw_text)
if cards:
formatted = format_flashcards(cards)
save_progress(data_dir, mode, cards, user_prompt)
return formatted, cards
return raw_text, []
return raw_text, []