Spaces:
Sleeping
Sleeping
| # import gradio as gr | |
| # import torch | |
| # import re | |
| # import os | |
| # import pypdf | |
| # import fitz # PyMuPDF | |
| # import cv2 | |
| # import numpy as np | |
| # import base64 | |
| # import json | |
| # import time | |
| # import nest_asyncio | |
| # import spacy | |
| # from transformers import ( | |
| # T5Tokenizer, | |
| # T5ForConditionalGeneration, | |
| # pipeline, | |
| # AutoModelForQuestionAnswering, | |
| # AutoTokenizer | |
| # ) | |
| # from transformers.pipelines import QuestionAnsweringPipeline | |
| # from peft import PeftModel | |
| # from sentence_transformers import CrossEncoder | |
| # from ultralytics import YOLO | |
| # # ========================================== | |
| # # 0. SPACY & VISION SETUP | |
| # # ========================================== | |
| # print("๐ง Booting Syntactic Parser...") | |
| # try: | |
| # nlp = spacy.load("en_core_web_sm") | |
| # except OSError: | |
| # os.system("python -m spacy download en_core_web_sm") | |
| # nlp = spacy.load("en_core_web_sm") | |
| # # ========================================== | |
| # # 1. HARDWARE & MODEL SETUP | |
| # # ========================================== | |
| # device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # # ๐ฅ FIXED: Defining the missing torch_dtype | |
| # torch_dtype = torch.float16 if device == "cuda" else torch.float32 | |
| # print(f"๐ Booting Edugenius Multimodal on: {device.upper()}") | |
| # # --- Text Models --- | |
| # model_id = "t5-base" | |
| # adapter_path = "." | |
| # print("โณ Loading T5 Generator...") | |
| # base_model = T5ForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch_dtype) | |
| # model = PeftModel.from_pretrained(base_model, adapter_path) | |
| # # tokenizer = T5Tokenizer.from_pretrained(adapter_path) | |
| # tokenizer = T5Tokenizer.from_pretrained("t5-base") | |
| # model.to(device).eval() | |
| # # --- Validation Models --- | |
| # print("โณ Loading Validation Guardrails...") | |
| # validator = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2-v2', device=device) | |
| # # qa_validator = pipeline("question-answering", model="deepset/minilm-uncased-squad2", device=0 if device == "cuda" else -1) | |
| # # qa_validator = pipeline("question-answering", model="deepset/minilm-uncased-squad2", device=0 if device == "cuda" else -1, trust_remote_code=True) | |
| # # --- FIXED: Explicit Round-Trip QA Construction --- | |
| # qa_model = AutoModelForQuestionAnswering.from_pretrained("deepset/minilm-uncased-squad2") | |
| # qa_tokenizer = AutoTokenizer.from_pretrained("deepset/minilm-uncased-squad2") | |
| # # qa_validator = QuestionAnsweringPipeline(model=qa_model, tokenizer=qa_tokenizer, device=0 if device == "cuda" else -1) | |
| # # --- FIXED: The Modern, Cross-Platform Way to Load QA --- | |
| # print("๐ต๏ธ Booting Round-Trip QA Validator...") | |
| # qa_validator = pipeline( | |
| # "question-answering", | |
| # model=qa_model, | |
| # tokenizer=qa_tokenizer, | |
| # device=0 if device == "cuda" else -1 | |
| # ) | |
| # # --- Vision Model (YOLO) --- | |
| # print("โณ Loading YOLO Figure Detector...") | |
| # YOLO_WEIGHTS = 'best.pt' | |
| # vision_model = YOLO(YOLO_WEIGHTS) | |
| # print("โ ALL MODELS LOADED AND READY.") | |
| # # ========================================== | |
| # # 2. UTILITY FUNCTIONS | |
| # # ========================================== | |
| # def get_paragraph_subject(paragraph_text): | |
| # doc = nlp(paragraph_text[:200]) | |
| # for chunk in doc.noun_chunks: | |
| # if chunk.root.dep_ in ('nsubj', 'nsubjpass') and chunk.root.pos_ in ('PROPN', 'NOUN'): | |
| # return chunk.text | |
| # return "The subject" | |
| # def chunk_text_by_sentence(text): | |
| # raw_sentences = text.replace('\n', ' ').split('.') | |
| # sentences = [s.strip() + '.' for s in raw_sentences if len(s.strip()) > 15] | |
| # chunks = [] | |
| # for i in range(len(sentences)): | |
| # if i == 0: | |
| # chunks.append(sentences[i]) | |
| # else: | |
| # chunks.append(sentences[i-1] + " " + sentences[i]) | |
| # return chunks | |
| # def process_pdf_text_clean(pdf_path): | |
| # """Extracts and cleans PDF text, removing page numbers and fixing wraps.""" | |
| # doc = fitz.open(pdf_path) | |
| # full_text = "" | |
| # for page in doc: | |
| # text = page.get_text("text") | |
| # if not text: continue | |
| # lines = text.split('\n') | |
| # cleaned_lines = [] | |
| # for line in lines: | |
| # line = line.strip() | |
| # if not line or line.isdigit(): continue | |
| # cleaned_lines.append(line) | |
| # page_text = "" | |
| # for line in cleaned_lines: | |
| # page_text += line | |
| # if not re.search(r'[.!?:]$', line): | |
| # page_text += " " | |
| # else: | |
| # page_text += "\n\n" | |
| # full_text += page_text + "\n\n" | |
| # doc.close() | |
| # return full_text | |
| # def detect_figures(pdf_path): | |
| # """Extracts only 'figure' class objects as Base64 strings.""" | |
| # doc = fitz.open(pdf_path) | |
| # visuals = [] | |
| # fig_idx = 1 | |
| # mat = fitz.Matrix(2.0, 2.0) | |
| # for page_num, page in enumerate(doc): | |
| # pix = page.get_pixmap(matrix=mat) | |
| # img = np.frombuffer(pix.samples, dtype=np.uint8).reshape((pix.h, pix.w, pix.n)) | |
| # img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) | |
| # results = vision_model.predict(img, conf=0.3, verbose=False) | |
| # for box in results[0].boxes: | |
| # cls = int(box.cls[0]) | |
| # if vision_model.names[cls] == 'figure': | |
| # x1, y1, x2, y2 = map(int, box.xyxy[0]) | |
| # crop = img[y1:y2, x1:x2] | |
| # _, buffer = cv2.imencode('.png', crop) | |
| # b64 = base64.b64encode(buffer).decode('utf-8') | |
| # visuals.append({ | |
| # "id": f"FIG_{fig_idx}", | |
| # "page": page_num + 1, | |
| # "base64": b64 | |
| # }) | |
| # fig_idx += 1 | |
| # doc.close() | |
| # return visuals | |
| # # ========================================== | |
| # # 3. CORE EXTRACTION ENGINE | |
| # # ========================================== | |
| # def extract_flashcards(text): | |
| # if not text or len(text.strip()) < 10: | |
| # yield "Please provide more text.", [] | |
| # return | |
| # accepted_cards = [] | |
| # accepted_markdown = "### โ Accepted Flashcards\n\n" | |
| # rejected_markdown = "### ๐ Blocked by Guardrails (Debug)\n\n" | |
| # seen_answers = set() | |
| # paragraphs = text.replace('\r', '').split('\n\n') | |
| # for paragraph in paragraphs: | |
| # if len(paragraph.strip()) < 20: continue | |
| # current_subject = get_paragraph_subject(paragraph) | |
| # chunks = chunk_text_by_sentence(paragraph) | |
| # for chunk in chunks: | |
| # grounded_chunk = f"{current_subject}: {chunk}" | |
| # prompt = f"task: generate Understand flashcard context: {grounded_chunk}" | |
| # inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(device) | |
| # with torch.no_grad(): | |
| # outputs = model.generate(**inputs, max_new_tokens=128, num_beams=4, early_stopping=True) | |
| # raw_output = tokenizer.decode(outputs[0], skip_special_tokens=True) | |
| # if "QS:" in raw_output and "| AN:" in raw_output: | |
| # try: | |
| # parts = raw_output.split("| AN:") | |
| # q = parts[0].replace("QS:", "").strip() | |
| # a = parts[1].strip() | |
| # if a.lower() in seen_answers or len(q) < 15 or len(a.split()) > 12: continue | |
| # seen_answers.add(a.lower()) | |
| # # --- NER & Syntax Check --- | |
| # ans_doc = nlp(a) | |
| # q_lower = q.lower() | |
| # first_word = q_lower.split()[0] if q_lower.split() else "" | |
| # ents = [ent.label_ for ent in ans_doc.ents] | |
| # blocked_reason = None | |
| # if first_word == "when" and not any(e in ents for e in ["DATE", "TIME", "CARDINAL"]): | |
| # blocked_reason = "NER: Expected Date" | |
| # elif first_word == "who" and not any(e in ents for e in ["PERSON", "ORG", "NORP"]): | |
| # blocked_reason = "NER: Expected Person" | |
| # elif first_word == "where" and not any(e in ents for e in ["GPE", "LOC"]): | |
| # blocked_reason = "NER: Expected Location" | |
| # if blocked_reason: | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: {blocked_reason})*\n\n---\n\n" | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # continue | |
| # # --- Semantic Check --- | |
| # score = validator.predict([[q, grounded_chunk]])[0] | |
| # if score < -4.0: | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: CE Score {score:.2f})*\n\n---\n\n" | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # continue | |
| # # --- Round-Trip QA Check --- | |
| # qa_res = qa_validator(question=q, context=grounded_chunk) | |
| # qa_a = qa_res['answer'].lower() | |
| # overlap = 1.0 if (a.lower() in qa_a or qa_a in a.lower()) else 0.0 | |
| # telemetry = f"*(**CE Score:** {score:.2f} | **QA Match:** {overlap:.0%} | **Subject:** \"{current_subject}\" | **Source:** \"{chunk.strip()}\")*" | |
| # if overlap < 0.5: | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: QA Mismatch. Predicted: {qa_a})*\n{telemetry}\n\n---\n\n" | |
| # else: | |
| # accepted_markdown += f"**Q:** {q}\n**A:** {a}\n{telemetry}\n\n---\n\n" | |
| # accepted_cards.append({"question": q, "answer": a, "subject": current_subject}) | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| # except Exception: continue | |
| # # ========================================== | |
| # # 4. FINAL GRADIO INTERFACE | |
| # # ========================================== | |
| # def run_pipeline(pdf_file): | |
| # if pdf_file is None: return "Upload a PDF.", None | |
| # # 1. Detect Visuals (Figures Only) | |
| # yield "๐ Step 1/3: Scanning for Figures...", None | |
| # visuals = detect_figures(pdf_file.name) | |
| # # 2. Extract Text | |
| # yield "๐ Step 2/3: Cleaning Text and Analyzing Subject...", None | |
| # clean_text = process_pdf_text_clean(pdf_file.name) | |
| # # 3. Extract Flashcards | |
| # final_cards = [] | |
| # md = "" | |
| # for md_update, card_list in extract_flashcards(clean_text): | |
| # md = md_update | |
| # final_cards = card_list | |
| # yield md, None | |
| # # 4. ๐ฅ REFORMAT FOR BACKEND SCHEMA WITH UUID | |
| # import uuid | |
| # backend_mcqs = [] | |
| # # Add Figures as Flashcards | |
| # for i, fig in enumerate(visuals): | |
| # backend_mcqs.append({ | |
| # "question": "Figure", | |
| # "answer": f"Visual from page {fig['page']}", | |
| # "question_type": "FLASHCARD", | |
| # "figure1": fig['base64'], | |
| # "documentIndex": i + 1, | |
| # "questionId": str(uuid.uuid4()), # ๐ฅ Secure Unique ID | |
| # "options": [], | |
| # "predicted_subject": None, | |
| # "predicted_concept": None | |
| # }) | |
| # # Add Text Flashcards | |
| # start_idx = len(backend_mcqs) + 1 | |
| # for i, card in enumerate(final_cards): | |
| # backend_mcqs.append({ | |
| # "question": card["question"], | |
| # "answer": card["answer"], | |
| # "question_type": "FLASHCARD", | |
| # "figure1": None, | |
| # "documentIndex": start_idx + i, | |
| # "questionId": str(uuid.uuid4()), # ๐ฅ Secure Unique ID | |
| # "options": [], | |
| # "predicted_subject": {"label": card["subject"], "confidence": 1.0}, | |
| # "predicted_concept": None | |
| # }) | |
| # # Wrap in the final structure | |
| # generated_id = str(uuid.uuid4()) | |
| # data_pack = [{ | |
| # "document": "Edugenius Generated", | |
| # "id": generated_id, | |
| # "metadata": { | |
| # "answerFound": True, | |
| # "card_count": len(backend_mcqs), | |
| # "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| # "file_name": os.path.basename(pdf_file.name), | |
| # "generatedQAId": generated_id, | |
| # "mcqs": backend_mcqs | |
| # } | |
| # }] | |
| # output_path = "edugenius_pack.json" | |
| # with open(output_path, "w") as f: | |
| # json.dump(data_pack, f, indent=2) | |
| # yield md + "\n\nโ **Processing Complete! Backend-compatible JSON Ready.**", output_path | |
| # with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
| # gr.Markdown("# ๐ Edugenius Multimodal Study-Pack Generator") | |
| # with gr.Row(): | |
| # file_in = gr.File(label="Upload Textbook (PDF)") | |
| # btn = gr.Button("Generate Study Pack", variant="primary") | |
| # with gr.Row(): | |
| # md_out = gr.Markdown(label="Real-time Extraction") | |
| # json_out = gr.File(label="Download Full Data (JSON)") | |
| # btn.click(run_pipeline, inputs=file_in, outputs=[md_out, json_out]) | |
| # demo.launch(share=True) | |
| # import gradio as gr | |
| # import torch | |
| # import re | |
| # import os | |
| # import fitz # PyMuPDF | |
| # import cv2 | |
| # import numpy as np | |
| # import base64 | |
| # import json | |
| # import time | |
| # import uuid | |
| # import spacy | |
| # from transformers import ( | |
| # T5Tokenizer, | |
| # T5ForConditionalGeneration, | |
| # ) | |
| # from peft import PeftModel | |
| # from sentence_transformers import CrossEncoder | |
| # from ultralytics import YOLO | |
| # # ========================================== | |
| # # 0. SPACY SETUP | |
| # # ========================================== | |
| # print("๐ง Booting Syntactic Parser...") | |
| # try: | |
| # nlp = spacy.load("en_core_web_sm") | |
| # except OSError: | |
| # os.system("python -m spacy download en_core_web_sm") | |
| # nlp = spacy.load("en_core_web_sm") | |
| # # # ========================================== | |
| # # # 1. HARDWARE & MODEL SETUP | |
| # # # ========================================== | |
| # # device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # # torch_dtype = torch.float16 if device == "cuda" else torch.float32 | |
| # # print(f"๐ Booting Edugenius Multimodal on: {device.upper()}") | |
| # # # --- Text Models --- | |
| # # model_id = "t5-base" | |
| # # adapter_path = "." | |
| # # print("โณ Loading T5 Generator...") | |
| # # base_model = T5ForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch_dtype) | |
| # # try: | |
| # # model = PeftModel.from_pretrained(base_model, adapter_path) | |
| # # except Exception as e: | |
| # # print(f"โ ๏ธ Could not load PeftModel adapter from {adapter_path}. Using base model. Error: {e}") | |
| # # model = base_model | |
| # # tokenizer = T5Tokenizer.from_pretrained("t5-base") | |
| # # model.to(device).eval() | |
| # import multiprocessing | |
| # # ========================================== | |
| # # 1. HARDWARE & MODEL SETUP | |
| # # ========================================== | |
| # device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # torch_dtype = torch.float16 if device == "cuda" else torch.float32 | |
| # # ๐ฅ FIX 1: WAKE UP THE CPU | |
| # # Force PyTorch to use ALL available CPU cores instead of defaulting to 1 | |
| # num_cores = multiprocessing.cpu_count() | |
| # torch.set_num_threads(num_cores) | |
| # os.environ["OMP_NUM_THREADS"] = str(num_cores) | |
| # print(f"๐ Booting Edugenius Multimodal on: {device.upper()} with {num_cores} Threads") | |
| # # --- Text Models --- | |
| # model_id = "t5-base" | |
| # adapter_path = "." | |
| # print("โณ Loading T5 Generator...") | |
| # base_model = T5ForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch_dtype) | |
| # try: | |
| # print("โณ Loading and Merging PEFT Adapter...") | |
| # peft_model = PeftModel.from_pretrained(base_model, adapter_path) | |
| # # ๐ฅ FIX 2: MERGE AND UNLOAD | |
| # # This bakes the LoRA weights into the base model. | |
| # # It removes the massive CPU calculation overhead during generation. | |
| # model = peft_model.merge_and_unload() | |
| # print("โ Adapter merged successfully!") | |
| # except Exception as e: | |
| # print(f"โ ๏ธ Could not load or merge PeftModel. Using base model. Error: {e}") | |
| # model = base_model | |
| # tokenizer = T5Tokenizer.from_pretrained("t5-base") | |
| # model.to(device).eval() | |
| # # --- Validation Models --- | |
| # print("โณ Loading Validation Guardrails (CrossEncoder Only)...") | |
| # validator = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2-v2', device=device) | |
| # # --- Vision Model (YOLO) --- | |
| # print("โณ Loading YOLO Figure Detector...") | |
| # YOLO_WEIGHTS = 'best.pt' | |
| # vision_model = YOLO(YOLO_WEIGHTS) | |
| # print("โ ALL MODELS LOADED AND READY.") | |
| # # ========================================== | |
| # # 2. UTILITY FUNCTIONS | |
| # # ========================================== | |
| # def get_paragraph_subject(paragraph_text): | |
| # doc = nlp(paragraph_text[:200]) | |
| # for chunk in doc.noun_chunks: | |
| # if chunk.root.dep_ in ('nsubj', 'nsubjpass') and chunk.root.pos_ in ('PROPN', 'NOUN'): | |
| # return chunk.text | |
| # return "The subject" | |
| # # def chunk_text_by_sentence(text): | |
| # # raw_sentences = text.replace('\n', ' ').split('.') | |
| # # sentences = [s.strip() + '.' for s in raw_sentences if len(s.strip()) > 15] | |
| # # chunks = [] | |
| # # for i in range(len(sentences)): | |
| # # if i == 0: | |
| # # chunks.append(sentences[i]) | |
| # # else: | |
| # # chunks.append(sentences[i-1] + " " + sentences[i]) | |
| # # return chunks | |
| # # def chunk_text_by_sentence(text): | |
| # # doc = nlp(text) | |
| # # # Only keep actual sentences (more than 5 words) | |
| # # sentences = [sent.text.strip() for sent in doc.sents if len(sent.text.split()) > 5] | |
| # # chunks = [] | |
| # # # Group 2 sentences together for better context | |
| # # for i in range(0, len(sentences), 2): | |
| # # chunk = " ".join(sentences[i:i+2]) | |
| # # chunks.append(chunk) | |
| # # return chunks | |
| # def chunk_text_by_sentence(text): | |
| # doc = nlp(text) | |
| # # Only keep actual sentences (more than 5 words) | |
| # sentences = [sent.text.strip() for sent in doc.sents if len(sent.text.split()) > 5] | |
| # chunks = [] | |
| # # SLIDING WINDOW: Overlap sentences to force the model to generate more questions | |
| # # from the same paragraph without using slow beam search. | |
| # for i in range(len(sentences)): | |
| # if i == 0: | |
| # # Chunk 1: Sentences 0 and 1 | |
| # chunk = " ".join(sentences[0:2]) | |
| # else: | |
| # # Chunk 2+: Sentences i-1 and i (This overlaps!) | |
| # chunk = " ".join(sentences[i-1:i+1]) | |
| # if chunk not in chunks: | |
| # chunks.append(chunk) | |
| # # Also add single sentences as their own chunks to guarantee granular detail extraction | |
| # for sent in sentences: | |
| # if sent not in chunks: | |
| # chunks.append(sent) | |
| # return chunks | |
| # # def process_pdf_text_clean(pdf_path): | |
| # # print(f"\n[DEBUG] Starting text extraction for {pdf_path}") | |
| # # doc = fitz.open(pdf_path) | |
| # # full_text = "" | |
| # # for page in doc: | |
| # # text = page.get_text("text") | |
| # # if not text: continue | |
| # # lines = text.split('\n') | |
| # # cleaned_lines = [] | |
| # # for line in lines: | |
| # # line = line.strip() | |
| # # if not line or line.isdigit(): continue | |
| # # cleaned_lines.append(line) | |
| # # page_text = "" | |
| # # for line in cleaned_lines: | |
| # # page_text += line | |
| # # if not re.search(r'[.!?:]$', line): | |
| # # page_text += " " | |
| # # else: | |
| # # page_text += "\n\n" | |
| # # full_text += page_text + "\n\n" | |
| # # doc.close() | |
| # # print(f"[DEBUG] Text extraction complete. Total length: {len(full_text)} characters.") | |
| # # return full_text | |
| # def process_pdf_text_clean(pdf_path): | |
| # print(f"\n[DEBUG] Starting clean text extraction for {pdf_path}") | |
| # doc = fitz.open(pdf_path) | |
| # full_text = "" | |
| # for page in doc: | |
| # text = page.get_text("text") | |
| # if not text: continue | |
| # lines = text.split('\n') | |
| # for line in lines: | |
| # line = line.strip() | |
| # # ๐ฅ THE FIX: Skip numbers, diagram labels (under 4 words), and figure text | |
| # word_count = len(line.split()) | |
| # if word_count < 4 or line.isdigit() or line.lower().startswith("figure") or "Reprint" in line: | |
| # continue | |
| # full_text += line + " " | |
| # doc.close() | |
| # # Clean up extra spaces | |
| # clean_result = re.sub(r'\s+', ' ', full_text).strip() | |
| # print(f"[DEBUG] Text cleaned. Length: {len(clean_result)} chars.") | |
| # return clean_result | |
| # def detect_figures(pdf_path): | |
| # print(f"\n[DEBUG] Starting YOLO figure detection for {pdf_path}") | |
| # doc = fitz.open(pdf_path) | |
| # visuals = [] | |
| # fig_idx = 1 | |
| # mat = fitz.Matrix(1.0, 1.0) | |
| # for page_num, page in enumerate(doc): | |
| # pix = page.get_pixmap(matrix=mat) | |
| # img = np.frombuffer(pix.samples, dtype=np.uint8).reshape((pix.h, pix.w, pix.n)) | |
| # img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) | |
| # results = vision_model.predict(img, conf=0.3, verbose=False) | |
| # for box in results[0].boxes: | |
| # cls = int(box.cls[0]) | |
| # if vision_model.names[cls] == 'figure': | |
| # x1, y1, x2, y2 = map(int, box.xyxy[0]) | |
| # crop = img[y1:y2, x1:x2] | |
| # _, buffer = cv2.imencode('.png', crop) | |
| # b64 = base64.b64encode(buffer).decode('utf-8') | |
| # visuals.append({ | |
| # "id": f"FIG_{fig_idx}", | |
| # "page": page_num + 1, | |
| # "base64": b64 | |
| # }) | |
| # fig_idx += 1 | |
| # print(f"[DEBUG] Found figure on page {page_num + 1}") | |
| # doc.close() | |
| # print(f"[DEBUG] Figure detection complete. Total figures: {len(visuals)}") | |
| # return visuals | |
| # # ========================================== | |
| # # 3. CORE EXTRACTION ENGINE | |
| # # ========================================== | |
| # def extract_flashcards(text): | |
| # if not text or len(text.strip()) < 10: | |
| # yield "Please provide more text.", [] | |
| # return | |
| # accepted_cards = [] | |
| # accepted_markdown = "### โ Accepted Flashcards\n\n" | |
| # rejected_markdown = "### ๐ Blocked by Guardrails (Debug)\n\n" | |
| # seen_answers = set() | |
| # paragraphs = text.replace('\r', '').split('\n\n') | |
| # print(f"\n[DEBUG] Starting extraction loop on {len(paragraphs)} paragraphs.") | |
| # for p_idx, paragraph in enumerate(paragraphs): | |
| # if len(paragraph.strip()) < 20: continue | |
| # current_subject = get_paragraph_subject(paragraph) | |
| # chunks = chunk_text_by_sentence(paragraph) | |
| # for c_idx, chunk in enumerate(chunks): | |
| # grounded_chunk = f"{current_subject}: {chunk}" | |
| # print(f"\n[DEBUG] --- Processing P{p_idx}-C{c_idx} ---") | |
| # print(f"[DEBUG] Chunk context: {grounded_chunk[:100]}...") | |
| # prompt = f"task: generate Understand flashcard context: {grounded_chunk}" | |
| # inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(device) | |
| # with torch.no_grad(): | |
| # outputs = model.generate(**inputs, max_new_tokens=128, num_beams=1) | |
| # raw_output = tokenizer.decode(outputs[0], skip_special_tokens=True) | |
| # print(f"[GEN] Raw T5 Output: {raw_output}") | |
| # if "QS:" in raw_output and "| AN:" in raw_output: | |
| # try: | |
| # parts = raw_output.split("| AN:") | |
| # q = parts[0].replace("QS:", "").strip() | |
| # a = parts[1].strip() | |
| # print(f"[GEN] Parsed Q: {q}") | |
| # print(f"[GEN] Parsed A: {a}") | |
| # if a.lower() in seen_answers or len(q) < 15 or len(a.split()) > 12: | |
| # print("[REJECT] Blocked by basic length or duplicate checks.") | |
| # continue | |
| # seen_answers.add(a.lower()) | |
| # # --- NER & Syntax Check --- | |
| # ans_doc = nlp(a) | |
| # q_lower = q.lower() | |
| # first_word = q_lower.split()[0] if q_lower.split() else "" | |
| # ents = [ent.label_ for ent in ans_doc.ents] | |
| # blocked_reason = None | |
| # if first_word == "when" and not any(e in ents for e in ["DATE", "TIME", "CARDINAL"]): | |
| # blocked_reason = "NER: Expected Date" | |
| # elif first_word == "who" and not any(e in ents for e in ["PERSON", "ORG", "NORP"]): | |
| # blocked_reason = "NER: Expected Person" | |
| # elif first_word == "where" and not any(e in ents for e in ["GPE", "LOC"]): | |
| # blocked_reason = "NER: Expected Location" | |
| # if blocked_reason: | |
| # print(f"[REJECT] {blocked_reason}") | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: {blocked_reason})*\n\n---\n\n" | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # continue | |
| # # --- Semantic Check --- | |
| # score = validator.predict([[q, grounded_chunk]])[0] | |
| # if score < -4.0: | |
| # print(f"[REJECT] Semantic score too low: {score:.2f}") | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: CE Score {score:.2f})*\n\n---\n\n" | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # continue | |
| # print(f"[ACCEPT] Semantic score passed: {score:.2f}") | |
| # telemetry = f"*(**CE Score:** {score:.2f} | **Subject:** \"{current_subject}\" | **Source:** \"{chunk.strip()}\")*" | |
| # accepted_markdown += f"**Q:** {q}\n**A:** {a}\n{telemetry}\n\n---\n\n" | |
| # accepted_cards.append({"question": q, "answer": a, "subject": current_subject}) | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| # except Exception as e: | |
| # print(f"[ERROR] Exception during output parsing: {e}") | |
| # continue | |
| # # ========================================== | |
| # # 4. FINAL GRADIO INTERFACE | |
| # # ========================================== | |
| # def run_pipeline(pdf_file): | |
| # if pdf_file is None: | |
| # print("[ERROR] No PDF file uploaded.") | |
| # return "Upload a PDF.", None | |
| # print(f"\n[START] Processing started for {pdf_file.name}") | |
| # # 1. Detect Visuals | |
| # yield "๐ Step 1/3: Scanning for Figures...", None | |
| # visuals = detect_figures(pdf_file.name) | |
| # # 2. Extract Text | |
| # yield "๐ Step 2/3: Cleaning Text and Analyzing Subject...", None | |
| # clean_text = process_pdf_text_clean(pdf_file.name) | |
| # # 3. Extract Flashcards | |
| # final_cards = [] | |
| # md = "" | |
| # for md_update, card_list in extract_flashcards(clean_text): | |
| # md = md_update | |
| # final_cards = card_list | |
| # yield md, None | |
| # # 4. REFORMAT FOR BACKEND SCHEMA WITH UUID | |
| # print("\n[DEBUG] Compiling final backend JSON package...") | |
| # backend_mcqs = [] | |
| # for i, fig in enumerate(visuals): | |
| # backend_mcqs.append({ | |
| # "question": "Figure", | |
| # "answer": f"Visual from page {fig['page']}", | |
| # "question_type": "FLASHCARD", | |
| # "figure1": fig['base64'], | |
| # "documentIndex": i + 1, | |
| # "questionId": str(uuid.uuid4()), | |
| # "options": [], | |
| # "predicted_subject": None, | |
| # "predicted_concept": None | |
| # }) | |
| # start_idx = len(backend_mcqs) + 1 | |
| # for i, card in enumerate(final_cards): | |
| # backend_mcqs.append({ | |
| # "question": card["question"], | |
| # "answer": card["answer"], | |
| # "question_type": "FLASHCARD", | |
| # "figure1": None, | |
| # "documentIndex": start_idx + i, | |
| # "questionId": str(uuid.uuid4()), | |
| # "options": [], | |
| # "predicted_subject": {"label": card["subject"], "confidence": 1.0}, | |
| # "predicted_concept": None | |
| # }) | |
| # generated_id = str(uuid.uuid4()) | |
| # data_pack = [{ | |
| # "document": "Edugenius Generated", | |
| # "id": generated_id, | |
| # "metadata": { | |
| # "answerFound": True, | |
| # "card_count": len(backend_mcqs), | |
| # "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| # "file_name": os.path.basename(pdf_file.name), | |
| # "generatedQAId": generated_id, | |
| # "mcqs": backend_mcqs | |
| # } | |
| # }] | |
| # output_path = "edugenius_pack.json" | |
| # with open(output_path, "w") as f: | |
| # json.dump(data_pack, f, indent=2) | |
| # print(f"[SUCCESS] Pipeline complete. Generated {len(backend_mcqs)} total items.") | |
| # yield md + "\n\nโ **Processing Complete! Backend-compatible JSON Ready.**", output_path | |
| # with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
| # gr.Markdown("# ๐ Edugenius Multimodal Study-Pack Generator (CPU Optimized)") | |
| # with gr.Row(): | |
| # file_in = gr.File(label="Upload Textbook (PDF)") | |
| # btn = gr.Button("Generate Study Pack", variant="primary") | |
| # with gr.Row(): | |
| # md_out = gr.Markdown(label="Real-time Extraction") | |
| # json_out = gr.File(label="Download Full Data (JSON)") | |
| # btn.click(run_pipeline, inputs=file_in, outputs=[md_out, json_out]) | |
| # demo.launch(share=True) | |
| # import gradio as gr | |
| # import torch | |
| # import re | |
| # import os | |
| # import fitz # PyMuPDF | |
| # import cv2 | |
| # import numpy as np | |
| # import base64 | |
| # import json | |
| # import time | |
| # import uuid | |
| # import spacy | |
| # import multiprocessing | |
| # from transformers import ( | |
| # T5Tokenizer, | |
| # T5ForConditionalGeneration, | |
| # ) | |
| # from peft import PeftModel | |
| # from sentence_transformers import CrossEncoder | |
| # from ultralytics import YOLO | |
| # # ========================================== | |
| # # 0. SPACY SETUP | |
| # # ========================================== | |
| # print("๐ง Booting Syntactic Parser...") | |
| # try: | |
| # nlp = spacy.load("en_core_web_sm") | |
| # except OSError: | |
| # os.system("python -m spacy download en_core_web_sm") | |
| # nlp = spacy.load("en_core_web_sm") | |
| # # ========================================== | |
| # # 1. HARDWARE & MODEL SETUP | |
| # # ========================================== | |
| # device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # torch_dtype = torch.float16 if device == "cuda" else torch.float32 | |
| # # ๐ฅ FIX 1: WAKE UP THE CPU | |
| # # Force PyTorch to use ALL available CPU cores instead of defaulting to 1 | |
| # num_cores = multiprocessing.cpu_count() | |
| # torch.set_num_threads(num_cores) | |
| # os.environ["OMP_NUM_THREADS"] = str(num_cores) | |
| # print(f"๐ Booting Edugenius Multimodal on: {device.upper()} with {num_cores} Threads") | |
| # # --- Text Models --- | |
| # model_id = "t5-base" | |
| # adapter_path = "." | |
| # print("โณ Loading T5 Generator...") | |
| # base_model = T5ForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch_dtype) | |
| # try: | |
| # print("โณ Loading and Merging PEFT Adapter...") | |
| # peft_model = PeftModel.from_pretrained(base_model, adapter_path) | |
| # # ๐ฅ FIX 2: MERGE AND UNLOAD | |
| # # This bakes the LoRA weights into the base model. | |
| # # It removes the massive CPU calculation overhead during generation. | |
| # model = peft_model.merge_and_unload() | |
| # print("โ Adapter merged successfully!") | |
| # except Exception as e: | |
| # print(f"โ ๏ธ Could not load or merge PeftModel. Using base model. Error: {e}") | |
| # model = base_model | |
| # tokenizer = T5Tokenizer.from_pretrained("t5-base") | |
| # model.to(device).eval() | |
| # # --- Validation Models --- | |
| # print("โณ Loading Validation Guardrails (CrossEncoder Only)...") | |
| # validator = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2-v2', device=device) | |
| # # --- Vision Model (YOLO) --- | |
| # print("โณ Loading YOLO Figure Detector...") | |
| # YOLO_WEIGHTS = 'best.pt' | |
| # vision_model = YOLO(YOLO_WEIGHTS) | |
| # print("โ ALL MODELS LOADED AND READY.") | |
| # # ========================================== | |
| # # 2. UTILITY FUNCTIONS | |
| # # ========================================== | |
| # def get_paragraph_subject(paragraph_text): | |
| # doc = nlp(paragraph_text[:200]) | |
| # for chunk in doc.noun_chunks: | |
| # if chunk.root.dep_ in ('nsubj', 'nsubjpass') and chunk.root.pos_ in ('PROPN', 'NOUN'): | |
| # return chunk.text | |
| # return "The subject" | |
| # # ๐ฅ FIX 4: MICRO-CHUNKING | |
| # def chunk_text_by_sentence(text): | |
| # doc = nlp(text) | |
| # chunks = [] | |
| # for sent in doc.sents: | |
| # s_text = sent.text.strip() | |
| # if len(s_text.split()) < 5: continue | |
| # # 1. Add the full sentence for broad context | |
| # chunks.append(s_text) | |
| # # 2. MICRO-CHUNKING: Split by clauses so the model is forced | |
| # # to look at isolated facts (like measurements or specific functions) | |
| # sub_clauses = re.split(r'(?:, which |, and |; |\()', s_text) | |
| # for clause in sub_clauses: | |
| # # Clean up trailing parentheses if we split on an open one | |
| # clause = clause.replace(')', '').strip() | |
| # if len(clause.split()) >= 4: | |
| # chunks.append(clause) | |
| # # Remove duplicates while preserving order | |
| # return list(dict.fromkeys(chunks)) | |
| # # ๐ฅ FIX 3: DIAGRAM LABEL CLEANER | |
| # def process_pdf_text_clean(pdf_path): | |
| # print(f"\n[DEBUG] Starting clean text extraction for {pdf_path}") | |
| # doc = fitz.open(pdf_path) | |
| # full_text = "" | |
| # for page in doc: | |
| # text = page.get_text("text") | |
| # if not text: continue | |
| # lines = text.split('\n') | |
| # for line in lines: | |
| # line = line.strip() | |
| # # Skip numbers, diagram labels (under 4 words), and figure text | |
| # word_count = len(line.split()) | |
| # if word_count < 4 or line.isdigit() or line.lower().startswith("figure") or "Reprint" in line: | |
| # continue | |
| # full_text += line + " " | |
| # doc.close() | |
| # # Clean up extra spaces | |
| # clean_result = re.sub(r'\s+', ' ', full_text).strip() | |
| # print(f"[DEBUG] Text cleaned. Length: {len(clean_result)} chars.") | |
| # return clean_result | |
| # def detect_figures(pdf_path): | |
| # print(f"\n[DEBUG] Starting YOLO figure detection for {pdf_path}") | |
| # doc = fitz.open(pdf_path) | |
| # visuals = [] | |
| # fig_idx = 1 | |
| # mat = fitz.Matrix(1.0, 1.0) | |
| # for page_num, page in enumerate(doc): | |
| # pix = page.get_pixmap(matrix=mat) | |
| # img = np.frombuffer(pix.samples, dtype=np.uint8).reshape((pix.h, pix.w, pix.n)) | |
| # img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) | |
| # results = vision_model.predict(img, conf=0.3, verbose=False) | |
| # for box in results[0].boxes: | |
| # cls = int(box.cls[0]) | |
| # if vision_model.names[cls] == 'figure': | |
| # x1, y1, x2, y2 = map(int, box.xyxy[0]) | |
| # crop = img[y1:y2, x1:x2] | |
| # _, buffer = cv2.imencode('.png', crop) | |
| # b64 = base64.b64encode(buffer).decode('utf-8') | |
| # visuals.append({ | |
| # "id": f"FIG_{fig_idx}", | |
| # "page": page_num + 1, | |
| # "base64": b64 | |
| # }) | |
| # fig_idx += 1 | |
| # print(f"[DEBUG] Found figure on page {page_num + 1}") | |
| # doc.close() | |
| # print(f"[DEBUG] Figure detection complete. Total figures: {len(visuals)}") | |
| # return visuals | |
| # # ========================================== | |
| # # 3. CORE EXTRACTION ENGINE | |
| # # ========================================== | |
| # def extract_flashcards(text): | |
| # if not text or len(text.strip()) < 10: | |
| # yield "Please provide more text.", [] | |
| # return | |
| # accepted_cards = [] | |
| # accepted_markdown = "### โ Accepted Flashcards\n\n" | |
| # rejected_markdown = "### ๐ Blocked by Guardrails (Debug)\n\n" | |
| # seen_answers = set() | |
| # paragraphs = text.replace('\r', '').split('\n\n') | |
| # print(f"\n[DEBUG] Starting extraction loop on {len(paragraphs)} paragraphs.") | |
| # for p_idx, paragraph in enumerate(paragraphs): | |
| # if len(paragraph.strip()) < 20: continue | |
| # current_subject = get_paragraph_subject(paragraph) | |
| # chunks = chunk_text_by_sentence(paragraph) | |
| # for c_idx, chunk in enumerate(chunks): | |
| # grounded_chunk = f"{current_subject}: {chunk}" | |
| # print(f"\n[DEBUG] --- Processing P{p_idx}-C{c_idx} ---") | |
| # print(f"[DEBUG] Chunk context: {grounded_chunk[:100]}...") | |
| # prompt = f"task: generate Understand flashcard context: {grounded_chunk}" | |
| # inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(device) | |
| # with torch.no_grad(): | |
| # # ๐ฅ FIX 6: BALANCE SPEED AND QUALITY WITH BEAMS=2 | |
| # outputs = model.generate(**inputs, max_new_tokens=128, num_beams=2, early_stopping=True) | |
| # raw_output = tokenizer.decode(outputs[0], skip_special_tokens=True) | |
| # print(f"[GEN] Raw T5 Output: {raw_output}") | |
| # if "QS:" in raw_output and "| AN:" in raw_output: | |
| # try: | |
| # parts = raw_output.split("| AN:") | |
| # q = parts[0].replace("QS:", "").strip() | |
| # a = parts[1].strip() | |
| # q_lower = q.lower() | |
| # a_lower = a.lower() | |
| # print(f"[GEN] Parsed Q: {q}") | |
| # print(f"[GEN] Parsed A: {a}") | |
| # if a_lower in seen_answers or len(q) < 15 or len(a.split()) > 15 or len(a) < 3: | |
| # print("[REJECT] Failed basic length/duplicate checks.") | |
| # continue | |
| # # ๐ฅ FIX 5: ADVANCED CIRCULAR Q/A CHECK | |
| # stop_words = {'what', 'is', 'are', 'the', 'a', 'an', 'of', 'in', 'to', 'for'} | |
| # q_words = set([w for w in re.findall(r'\b\w+\b', q_lower) if w not in stop_words]) | |
| # a_words = set([w for w in re.findall(r'\b\w+\b', a_lower) if w not in stop_words]) | |
| # if a_words.issubset(q_words) or q_words.issubset(a_words): | |
| # print("[REJECT] Answer matches question too closely (Circular).") | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: Circular Answer)*\n\n---\n\n" | |
| # # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| # continue | |
| # seen_answers.add(a_lower) | |
| # # --- NER & Syntax Check --- | |
| # ans_doc = nlp(a) | |
| # first_word = q_lower.split()[0] if q_lower.split() else "" | |
| # ents = [ent.label_ for ent in ans_doc.ents] | |
| # blocked_reason = None | |
| # if first_word == "when" and not any(e in ents for e in ["DATE", "TIME", "CARDINAL"]): | |
| # blocked_reason = "NER: Expected Date" | |
| # elif first_word == "who" and not any(e in ents for e in ["PERSON", "ORG", "NORP"]): | |
| # blocked_reason = "NER: Expected Person" | |
| # elif first_word == "where" and not any(e in ents for e in ["GPE", "LOC"]): | |
| # blocked_reason = "NER: Expected Location" | |
| # if blocked_reason: | |
| # print(f"[REJECT] {blocked_reason}") | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: {blocked_reason})*\n\n---\n\n" | |
| # # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| # continue | |
| # # --- Semantic Check --- | |
| # score = validator.predict([[q, grounded_chunk]])[0] | |
| # if score < -4.0: | |
| # print(f"[REJECT] Semantic score too low: {score:.2f}") | |
| # rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: CE Score {score:.2f})*\n\n---\n\n" | |
| # # yield accepted_markdown + "\n\n" + rejected_markdown, [] | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| # continue | |
| # print(f"[ACCEPT] Flashcard passed all checks! (CE Score: {score:.2f})") | |
| # # telemetry = f"*(**CE Score:** {score:.2f} | **Subject:** \"{current_subject}\")*" | |
| # telemetry = f"*(**CE Score:** {score:.2f} | **Subject:** \"{current_subject}\" | **Source:** \"{chunk.strip()}\")*" | |
| # accepted_markdown += f"**Q:** {q}\n**A:** {a}\n{telemetry}\n\n---\n\n" | |
| # accepted_cards.append({"question": q, "answer": a, "subject": current_subject}) | |
| # yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| # except Exception as e: | |
| # print(f"[ERROR] Parsing exception: {e}") | |
| # continue | |
| # # ========================================== | |
| # # 4. FINAL GRADIO INTERFACE | |
| # # ========================================== | |
| # def run_pipeline(pdf_file): | |
| # if pdf_file is None: | |
| # print("[ERROR] No PDF file uploaded.") | |
| # return "Upload a PDF.", None | |
| # print(f"\n[START] Processing started for {pdf_file.name}") | |
| # # 1. Detect Visuals | |
| # yield "๐ Step 1/3: Scanning for Figures...", None | |
| # visuals = detect_figures(pdf_file.name) | |
| # # 2. Extract Text | |
| # yield "๐ Step 2/3: Cleaning Text and Analyzing Subject...", None | |
| # clean_text = process_pdf_text_clean(pdf_file.name) | |
| # # 3. Extract Flashcards | |
| # final_cards = [] | |
| # md = "" | |
| # for md_update, card_list in extract_flashcards(clean_text): | |
| # md = md_update | |
| # final_cards = card_list | |
| # yield md, None | |
| # # 4. REFORMAT FOR BACKEND SCHEMA WITH UUID | |
| # print("\n[DEBUG] Compiling final backend JSON package...") | |
| # backend_mcqs = [] | |
| # for i, fig in enumerate(visuals): | |
| # backend_mcqs.append({ | |
| # "question": "Figure", | |
| # "answer": f"Visual from page {fig['page']}", | |
| # "question_type": "FLASHCARD", | |
| # "figure1": fig['base64'], | |
| # "documentIndex": i + 1, | |
| # "questionId": str(uuid.uuid4()), | |
| # "options": [], | |
| # "predicted_subject": None, | |
| # "predicted_concept": None | |
| # }) | |
| # start_idx = len(backend_mcqs) + 1 | |
| # for i, card in enumerate(final_cards): | |
| # backend_mcqs.append({ | |
| # "question": card["question"], | |
| # "answer": card["answer"], | |
| # "question_type": "FLASHCARD", | |
| # "figure1": None, | |
| # "documentIndex": start_idx + i, | |
| # "questionId": str(uuid.uuid4()), | |
| # "options": [], | |
| # "predicted_subject": {"label": card["subject"], "confidence": 1.0}, | |
| # "predicted_concept": None | |
| # }) | |
| # generated_id = str(uuid.uuid4()) | |
| # data_pack = [{ | |
| # "document": "Edugenius Generated", | |
| # "id": generated_id, | |
| # "metadata": { | |
| # "answerFound": True, | |
| # "card_count": len(backend_mcqs), | |
| # "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| # "file_name": os.path.basename(pdf_file.name), | |
| # "generatedQAId": generated_id, | |
| # "mcqs": backend_mcqs | |
| # } | |
| # }] | |
| # output_path = "edugenius_pack.json" | |
| # with open(output_path, "w") as f: | |
| # json.dump(data_pack, f, indent=2) | |
| # print(f"[SUCCESS] Pipeline complete. Generated {len(backend_mcqs)} total items.") | |
| # yield md + "\n\nโ **Processing Complete! Backend-compatible JSON Ready.**", output_path | |
| # with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
| # gr.Markdown("# ๐ Edugenius Multimodal Study-Pack Generator (CPU Optimized)") | |
| # with gr.Row(): | |
| # file_in = gr.File(label="Upload Textbook (PDF)") | |
| # btn = gr.Button("Generate Study Pack", variant="primary") | |
| # with gr.Row(): | |
| # md_out = gr.Markdown(label="Real-time Extraction") | |
| # json_out = gr.File(label="Download Full Data (JSON)") | |
| # btn.click(run_pipeline, inputs=file_in, outputs=[md_out, json_out]) | |
| # demo.launch(share=True) | |
| import gradio as gr | |
| import torch | |
| import re | |
| import os | |
| import fitz # PyMuPDF | |
| import cv2 | |
| import numpy as np | |
| import base64 | |
| import json | |
| import time | |
| import uuid | |
| import spacy | |
| import multiprocessing | |
| from transformers import ( | |
| T5Tokenizer, | |
| T5ForConditionalGeneration, | |
| ) | |
| from peft import PeftModel | |
| from sentence_transformers import CrossEncoder | |
| from ultralytics import YOLO | |
| # ========================================== | |
| # 0. SPACY & COREF SETUP | |
| # ========================================== | |
| print("๐ง Booting Syntactic Parser...") | |
| try: | |
| nlp = spacy.load("en_core_web_sm") | |
| except OSError: | |
| os.system("python -m spacy download en_core_web_sm") | |
| nlp = spacy.load("en_core_web_sm") | |
| print("๐ง Booting Coreference Resolution...") | |
| try: | |
| from fastcoref import FCoref | |
| from fastcoref.modeling import FCorefModel | |
| # --- MONKEY PATCH --- | |
| # Fixes compatibility issues with modern transformers (>=4.38.0) | |
| # Force overwrite in case a previous notebook run left it as a set() | |
| FCorefModel.all_tied_weights_keys = {} | |
| coref_device = 'cuda:0' if torch.cuda.is_available() else 'cpu' | |
| coref_model = FCoref(device=coref_device) | |
| USE_NEURAL_COREF = True | |
| print("โ FastCoref loaded successfully.") | |
| except Exception as e: | |
| print(f"โ ๏ธ Neural coref failed to load. Falling back to heuristic tracking. Error: {e}") | |
| USE_NEURAL_COREF = False | |
| # ========================================== | |
| # 1. HARDWARE & MODEL SETUP | |
| # ========================================== | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| torch_dtype = torch.float16 if device == "cuda" else torch.float32 | |
| # Force PyTorch to use ALL available CPU cores instead of defaulting to 1 | |
| num_cores = multiprocessing.cpu_count() | |
| torch.set_num_threads(num_cores) | |
| os.environ["OMP_NUM_THREADS"] = str(num_cores) | |
| print(f"๐ Booting Multimodal Generator on: {device.upper()} with {num_cores} Threads") | |
| # --- Text Models --- | |
| model_id = "t5-base" | |
| adapter_path = "." | |
| print("โณ Loading T5 Generator...") | |
| base_model = T5ForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch_dtype) | |
| try: | |
| print("โณ Loading and Merging PEFT Adapter...") | |
| peft_model = PeftModel.from_pretrained(base_model, adapter_path) | |
| model = peft_model.merge_and_unload() | |
| print("โ Adapter merged successfully!") | |
| except Exception as e: | |
| print(f"โ ๏ธ Could not load or merge PeftModel. Using base model. Error: {e}") | |
| model = base_model | |
| tokenizer = T5Tokenizer.from_pretrained("t5-base") | |
| model.to(device).eval() | |
| # --- Validation Models --- | |
| print("โณ Loading Validation Guardrails (CrossEncoder Only)...") | |
| validator = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2-v2', device=device) | |
| # --- Vision Model (YOLO) --- | |
| print("โณ Loading YOLO Figure Detector...") | |
| YOLO_WEIGHTS = 'best.pt' | |
| vision_model = YOLO(YOLO_WEIGHTS) | |
| print("โ ALL MODELS LOADED AND READY.") | |
| # ========================================== | |
| # 2. UTILITY FUNCTIONS | |
| # ========================================== | |
| def resolve_paragraph_coref(paragraph): | |
| """Resolves pronouns across the entire paragraph before chunking.""" | |
| if len(paragraph.split()) < 5: | |
| return paragraph | |
| if USE_NEURAL_COREF: | |
| try: | |
| # fastcoref processes lists of strings | |
| preds = coref_model.predict(texts=[paragraph]) | |
| # get_resolved_text returns the text with pronouns swapped for nouns | |
| resolved_text = preds[0].get_resolved_text() | |
| return resolved_text | |
| except Exception as e: | |
| print(f"[DEBUG] Neural Coref failed on a paragraph, falling back. Error: {e}") | |
| pass | |
| # Smart Heuristic Fallback: Tracks the actual grammatical subject sentence-to-sentence | |
| doc = nlp(paragraph) | |
| resolved_sentences = [] | |
| last_subject = None | |
| for sent in doc.sents: | |
| text = sent.text.strip() | |
| if not text: continue | |
| first_token = sent[0] | |
| # If sentence starts with a pronoun, replace with the last known valid subject | |
| if first_token.pos_ == "PRON" and last_subject: | |
| text = re.sub(r'^' + re.escape(first_token.text), last_subject, text, count=1, flags=re.IGNORECASE) | |
| # Update the active subject for the NEXT sentence | |
| for token in sent: | |
| if "subj" in token.dep_ and token.pos_ in ["NOUN", "PROPN"]: | |
| # Extract the noun phrase | |
| last_subject = "".join([w.text_with_ws for w in token.subtree]).strip() | |
| break | |
| resolved_sentences.append(text) | |
| return " ".join(resolved_sentences) | |
| def get_sentence_sub_subject(chunk_text, main_subject): | |
| """Extracts prominent noun chunks, ignoring generic stop-words.""" | |
| doc = nlp(chunk_text) | |
| # Priority 1: Check for explicit Named Entities first | |
| for ent in doc.ents: | |
| if ent.label_ not in ['DATE', 'TIME', 'PERCENT', 'MONEY', 'CARDINAL', 'ORDINAL', 'QUANTITY']: | |
| if main_subject.lower() not in ent.text.lower() and len(ent.text) > 2: | |
| return ent.text.strip('.,;()') | |
| # Priority 2: Filtered Noun Chunks | |
| candidates = [] | |
| stop_nouns = {'presence', 'years', 'conditions', 'majority', 'most', 'some', 'part', 'type', 'number', 'numbers', 'example', 'organisms', 'surface', 'them'} | |
| for chunk in doc.noun_chunks: | |
| root = chunk.root | |
| if root.pos_ in ['PRON', 'DET']: continue | |
| if main_subject.lower() in chunk.text.lower(): continue | |
| if root.text.lower() in stop_nouns: continue | |
| if root.dep_ in ('attr', 'dobj', 'pobj', 'appos', 'nsubjpass'): | |
| clean_text = chunk.text.strip('.,;()') | |
| if len(clean_text) > 2: | |
| candidates.append(clean_text) | |
| return candidates[0] if candidates else None | |
| def chunk_text_by_blocks(text, block_size=2): | |
| """ | |
| Groups sentences into larger contextual blocks rather than | |
| forcing the model to evaluate every single sentence or clause. | |
| """ | |
| doc = nlp(text) | |
| # Filter out junk/tiny sentences | |
| valid_sentences = [sent.text.strip() for sent in doc.sents if len(sent.text.split()) >= 5] | |
| chunks = [] | |
| # Group sentences into blocks of 'block_size' | |
| for i in range(0, len(valid_sentences), block_size): | |
| block = " ".join(valid_sentences[i:i + block_size]) | |
| chunks.append(block) | |
| return list(dict.fromkeys(chunks)) | |
| def extract_structured_text(pdf_path): | |
| print(f"\n[DEBUG] Starting structured text extraction for {pdf_path}") | |
| doc = fitz.open(pdf_path) | |
| structured_blocks = [] | |
| current_header = "General Context" | |
| for page_num, page in enumerate(doc): | |
| page_dict = page.get_text("dict") | |
| blocks = page_dict.get("blocks", []) | |
| for block in blocks: | |
| if block.get("type") != 0: | |
| continue | |
| block_text = "" | |
| is_bold = False | |
| max_font_size = 0 | |
| for line in block.get("lines", []): | |
| for span in line.get("spans", []): | |
| text = span.get("text", "").strip() | |
| if not text: continue | |
| block_text += text + " " | |
| if span.get("size", 0) > max_font_size: | |
| max_font_size = span.get("size") | |
| if "bold" in span.get("font", "").lower(): | |
| is_bold = True | |
| block_text = block_text.strip() | |
| word_count = len(block_text.split()) | |
| # --- CLEANING HEURISTICS --- | |
| if word_count < 4 and block_text.replace(".", "").isdigit(): | |
| continue | |
| if block_text.lower().startswith("figure") or "Reprint" in block_text: | |
| continue | |
| # --- HEADER DETECTION --- | |
| if word_count < 10 and (max_font_size > 11.5 or is_bold): | |
| clean_header = re.sub(r'^\d+(\.\d+)*\s*', '', block_text).strip() | |
| # NEW: Ignore chapters, summaries, and long all-caps text (running headers) | |
| lower_header = clean_header.lower() | |
| if lower_header.startswith("chapter") or "summary" in lower_header or "exercises" in lower_header: | |
| continue | |
| if clean_header.isupper() and word_count > 3: | |
| continue | |
| current_header = clean_header | |
| print(f"[DEBUG] Found New Header: {current_header}") | |
| else: | |
| if word_count >= 10: | |
| structured_blocks.append({ | |
| "header": current_header, | |
| "text": block_text | |
| }) | |
| doc.close() | |
| return structured_blocks | |
| def detect_figures(pdf_path): | |
| print(f"\n[DEBUG] Starting YOLO figure detection for {pdf_path}") | |
| doc = fitz.open(pdf_path) | |
| visuals = [] | |
| fig_idx = 1 | |
| mat = fitz.Matrix(1.0, 1.0) | |
| for page_num, page in enumerate(doc): | |
| pix = page.get_pixmap(matrix=mat) | |
| img = np.frombuffer(pix.samples, dtype=np.uint8).reshape((pix.h, pix.w, pix.n)) | |
| img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) | |
| results = vision_model.predict(img, conf=0.3, verbose=False) | |
| for box in results[0].boxes: | |
| cls = int(box.cls[0]) | |
| if vision_model.names[cls] == 'figure': | |
| x1, y1, x2, y2 = map(int, box.xyxy[0]) | |
| crop = img[y1:y2, x1:x2] | |
| _, buffer = cv2.imencode('.png', crop) | |
| b64 = base64.b64encode(buffer).decode('utf-8') | |
| visuals.append({ | |
| "id": f"FIG_{fig_idx}", | |
| "page": page_num + 1, | |
| "base64": b64 | |
| }) | |
| fig_idx += 1 | |
| print(f"[DEBUG] Found figure on page {page_num + 1}") | |
| doc.close() | |
| print(f"[DEBUG] Figure detection complete. Total figures: {len(visuals)}") | |
| return visuals | |
| # ========================================== | |
| # 3. CORE EXTRACTION ENGINE | |
| # ========================================== | |
| def extract_flashcards(structured_blocks): | |
| if not structured_blocks: | |
| yield "Please provide more text.", [] | |
| return | |
| accepted_cards = [] | |
| accepted_markdown = "### โ Accepted Flashcards\n\n" | |
| rejected_markdown = "### ๐ Blocked by Guardrails (Debug)\n\n" | |
| seen_answers = set() | |
| print(f"\n[DEBUG] Starting extraction loop on {len(structured_blocks)} structured blocks.") | |
| for b_idx, block in enumerate(structured_blocks): | |
| current_subject = block["header"] | |
| raw_paragraph = block["text"] | |
| # 1. Resolve coreferences on the ENTIRE paragraph | |
| resolved_paragraph = resolve_paragraph_coref(raw_paragraph) | |
| # 2. Chunk the resolved text into 2-sentence blocks | |
| chunks = chunk_text_by_blocks(resolved_paragraph, block_size=2) | |
| for c_idx, chunk in enumerate(chunks): | |
| # 3. Extract sub-subject for metadata | |
| sub_subject = get_sentence_sub_subject(chunk, current_subject) | |
| # 4. Format prompt | |
| grounded_chunk = f"{current_subject}: {chunk}" | |
| print(f"\n[DEBUG] --- Processing B{b_idx}-C{c_idx} ---") | |
| print(f"[DEBUG] Prompt Context: {grounded_chunk[:100]}...") | |
| prompt = f"task: generate Understand flashcard context: {grounded_chunk}" | |
| inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(device) | |
| with torch.no_grad(): | |
| outputs = model.generate(**inputs, max_new_tokens=128, num_beams=2, early_stopping=True) | |
| raw_output = tokenizer.decode(outputs[0], skip_special_tokens=True) | |
| print(f"[GEN] Raw T5 Output: {raw_output}") | |
| if "QS:" in raw_output and "| AN:" in raw_output: | |
| try: | |
| parts = raw_output.split("| AN:") | |
| q = parts[0].replace("QS:", "").strip() | |
| a = parts[1].strip() | |
| q_lower = q.lower() | |
| a_lower = a.lower() | |
| if a_lower in seen_answers or len(q) < 15 or len(a.split()) > 15 or len(a) < 3: | |
| print("[REJECT] Failed basic length/duplicate checks.") | |
| continue | |
| # --- ADVANCED CIRCULAR Q/A CHECK --- | |
| stop_words = {'what', 'is', 'are', 'the', 'a', 'an', 'of', 'in', 'to', 'for'} | |
| q_words = set([w for w in re.findall(r'\b\w+\b', q_lower) if w not in stop_words]) | |
| a_words = set([w for w in re.findall(r'\b\w+\b', a_lower) if w not in stop_words]) | |
| if a_words.issubset(q_words) or q_words.issubset(a_words): | |
| print("[REJECT] Answer matches question too closely (Circular).") | |
| rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: Circular Answer)*\n\n---\n\n" | |
| yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| continue | |
| seen_answers.add(a_lower) | |
| # --- NER & Syntax Check --- | |
| ans_doc = nlp(a) | |
| first_word = q_lower.split()[0] if q_lower.split() else "" | |
| ents = [ent.label_ for ent in ans_doc.ents] | |
| blocked_reason = None | |
| if first_word == "when" and not any(e in ents for e in ["DATE", "TIME", "CARDINAL"]): | |
| blocked_reason = "NER: Expected Date" | |
| if blocked_reason: | |
| print(f"[REJECT] {blocked_reason}") | |
| rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: {blocked_reason})*\n\n---\n\n" | |
| yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| continue | |
| # --- SUBJECT INJECTION (Restored and Improved) --- | |
| # We only inject if it's a real topic, not the default "General Context" | |
| if current_subject and current_subject != "General Context" and current_subject.lower() not in q_lower: | |
| if q_lower.startswith(("what", "how", "why", "when", "where", "who", "which")): | |
| q = f"Regarding {current_subject}, {q[0].lower()}{q[1:]}" | |
| else: | |
| q = f"For {current_subject}, {q[0].lower()}{q[1:]}" | |
| print(f"[REFINED] New Question: {q}") | |
| # --- Semantic Check --- | |
| # CRITICAL FIX: Evaluate against the grounded_chunk so the CE model | |
| # knows what subject is being talked about and doesn't falsely block it. | |
| score = validator.predict([[q, grounded_chunk]])[0] | |
| if score < -3.5: | |
| print(f"[REJECT] Semantic score too low: {score:.2f}") | |
| rejected_markdown += f"~~**Q:** {q}~~\n*(Blocked: CE Score {score:.2f})*\n\n---\n\n" | |
| yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| continue | |
| print(f"[ACCEPT] Flashcard passed all checks! (CE Score: {score:.2f})") | |
| telemetry_sub = f" | **Sub-Subject:** \"{sub_subject}\"" if sub_subject else "" | |
| telemetry = f"*(**CE Score:** {score:.2f} | **Topic Header:** \"{current_subject}\"{telemetry_sub} | **Source:** \"{chunk.strip()}\")*" | |
| accepted_markdown += f"**Q:** {q}\n**A:** {a}\n{telemetry}\n\n---\n\n" | |
| accepted_cards.append({ | |
| "question": q, | |
| "answer": a, | |
| "subject": current_subject, | |
| "sub_subject": sub_subject | |
| }) | |
| yield accepted_markdown + "\n\n" + rejected_markdown, accepted_cards | |
| except Exception as e: | |
| print(f"[ERROR] Parsing exception: {e}") | |
| continue | |
| # ========================================== | |
| # 4. FINAL GRADIO INTERFACE | |
| # ========================================== | |
| def run_pipeline(pdf_file): | |
| if pdf_file is None: | |
| print("[ERROR] No PDF file uploaded.") | |
| return "Upload a PDF.", None | |
| print(f"\n[START] Processing started for {pdf_file.name}") | |
| # 1. Detect Visuals | |
| yield "๐ Step 1/3: Scanning for Figures...", None | |
| visuals = detect_figures(pdf_file.name) | |
| # 2. Extract Text | |
| yield "๐ Step 2/3: Analyzing Document Layout and Context...", None | |
| structured_data = extract_structured_text(pdf_file.name) | |
| # 3. Extract Flashcards | |
| final_cards = [] | |
| md = "" | |
| for md_update, card_list in extract_flashcards(structured_data): | |
| md = md_update | |
| final_cards = card_list | |
| yield md, None | |
| # 4. REFORMAT FOR BACKEND SCHEMA WITH UUID | |
| print("\n[DEBUG] Compiling final backend JSON package...") | |
| backend_mcqs = [] | |
| for i, fig in enumerate(visuals): | |
| backend_mcqs.append({ | |
| "question": "Figure", | |
| "answer": f"Visual from page {fig['page']}", | |
| "question_type": "FLASHCARD", | |
| "figure1": fig['base64'], | |
| "documentIndex": i + 1, | |
| "questionId": str(uuid.uuid4()), | |
| "options": [], | |
| "predicted_subject": None, | |
| "predicted_concept": None | |
| }) | |
| start_idx = len(backend_mcqs) + 1 | |
| for i, card in enumerate(final_cards): | |
| backend_mcqs.append({ | |
| "question": card["question"], | |
| "answer": card["answer"], | |
| "question_type": "FLASHCARD", | |
| "figure1": None, | |
| "documentIndex": start_idx + i, | |
| "questionId": str(uuid.uuid4()), | |
| "options": [], | |
| "predicted_subject": {"label": card["subject"], "confidence": 1.0}, | |
| "predicted_concept": {"label": card["sub_subject"], "confidence": 1.0} if card["sub_subject"] else None | |
| }) | |
| generated_id = str(uuid.uuid4()) | |
| data_pack = [{ | |
| "document": "AI Generated", | |
| "id": generated_id, | |
| "metadata": { | |
| "answerFound": True, | |
| "card_count": len(backend_mcqs), | |
| "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| "file_name": os.path.basename(pdf_file.name), | |
| "generatedQAId": generated_id, | |
| "mcqs": backend_mcqs | |
| } | |
| }] | |
| output_path = "study_pack.json" | |
| with open(output_path, "w") as f: | |
| json.dump(data_pack, f, indent=2) | |
| print(f"[SUCCESS] Pipeline complete. Generated {len(backend_mcqs)} total items.") | |
| yield md + "\n\nโ **Processing Complete! Backend-compatible JSON Ready.**", output_path | |
| with gr.Blocks() as demo: | |
| gr.Markdown("# ๐ Multimodal Study-Pack Generator") | |
| with gr.Row(): | |
| file_in = gr.File(label="Upload Textbook (PDF)") | |
| btn = gr.Button("Generate Study Pack", variant="primary") | |
| with gr.Row(): | |
| md_out = gr.Markdown(label="Real-time Extraction") | |
| json_out = gr.File(label="Download Full Data (JSON)") | |
| btn.click(run_pipeline, inputs=file_in, outputs=[md_out, json_out]) | |
| if __name__ == "__main__": | |
| demo.launch(share=True) |