"""Additional extraction modes that run alongside (not replacing) the standard extractor. Modes ----- questions_only Extract Q+options with no answers. User fills answers later via QuizEditPage. two_step Separate answer key section (PREP 2013): Phase 1 = questions, Phase 2 = key, Phase 3 = match. regex AI generates a regex pattern for the document's answer format, then we apply it. ai_decide AI samples the document and picks standard / two_step / questions_only. generate AI reads plain text/study material and creates MCQ questions from scratch. """ from __future__ import annotations import json import logging import re from app.services import ai_service, vector_service logger = logging.getLogger(__name__) def _normalize(text: str) -> str: return (text.replace("Pref erred", "Preferred").replace("Pre ferred", "Preferred") .replace("Prefer red", "Preferred").replace("ltem", "Item").replace("ltcm", "Item")) # ─── QUESTIONS ONLY ────────────────────────────────────────────────────────── QUESTIONS_ONLY_PROMPT = """Extract every question from this PREP exam content. Do NOT look for correct answers — we only need the question text and answer options. Return ONLY JSON: {{"questions": [ {{ "item_number": "", "question_text": "", "question_type": "mcq", "options": ["", "", "", "", ""], "has_figure": false, "page_reference": {page_ref} }} ]}} Rules: - A new question starts with "Item NNN" or "ltem NNN". - Extract ALL questions even if no answer is visible. - Do NOT include answer explanations or "Preferred Response:" content as questions. - has_figure: true ONLY if the question references an image, figure, radiograph, photo, ECG, or chart essential to answering. false for decorative/branding images. - Return ONLY JSON — no markdown, no preamble. Content (pages {page_info}): {content}""" def extract_questions_only(content: str, page_info: str, page_ref: int | None, model_id: str | None, api_key: str | None) -> list[dict]: """Extract questions + options with correct_answer = PENDING (to be filled by admin).""" content = ai_service._truncate_content(content) prompt = QUESTIONS_ONLY_PROMPT.format( content=content, page_info=page_info, page_ref=page_ref if page_ref else "null", ) for attempt in range(3): try: text = ai_service._call_model(prompt, model_id, api_key).strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] text = text.strip() data = json.loads(text) qs = data.get("questions", data) if isinstance(data, dict) else data result = [] for q in qs: if not q.get("question_text"): continue result.append({ "item_number": str(q.get("item_number") or "").strip().lstrip("0") or None, "question_text": q["question_text"], "question_type": q.get("question_type", "mcq"), "options": q.get("options"), "correct_answer": "PENDING", # placeholder — admin fills via QuizEditPage "explanation": "", "page_reference": q.get("page_reference"), }) if result: return result raise ValueError("No questions found") except Exception as e: logger.warning(f"questions_only attempt {attempt + 1}: {e}") raise RuntimeError("questions_only extraction failed after 3 attempts") # ─── AI-ANSWERED (no inline/back answers — AI deduces correct answer) ─────── AI_ANSWER_PROMPT = """Pick the correct answer for each question below using medical knowledge and any supporting context from the provided document excerpt. Return ONLY JSON: {{"answers": [ {{ "item_number": "", "correct_answer": "", "explanation": "<1-3 sentence explanation; cite the document if relevant, otherwise general medical reasoning>" }} ]}} Rules: - correct_answer must be the EXACT text of one of the options (not "A", "B", etc.) - Prefer wording and reasoning supported by the Document context — fall back to general medical knowledge only when the document doesn't address it - Keep explanations focused on why the answer is correct and briefly why others are wrong Document context (pages {page_info}): {content} Questions (JSON list): {questions_json}""" def ai_answer_questions(questions: list[dict], content: str, page_info: str, model_id: str | None, api_key: str | None) -> list[dict]: """For questions extracted without answers, use AI to determine correct answer and explanation, using the document content as supporting context. Mutates and returns the questions list.""" if not questions: return questions # Strip-down representation to keep the prompt small qs_for_prompt = [ { "item_number": str(q.get("item_number") or "").strip() or None, "question_text": q.get("question_text", ""), "options": q.get("options") or [], } for q in questions ] content = ai_service._truncate_content(content) prompt = AI_ANSWER_PROMPT.format( page_info=page_info, content=content, questions_json=json.dumps(qs_for_prompt, ensure_ascii=False), ) for attempt in range(3): try: text = ai_service._call_model(prompt, model_id, api_key).strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] text = text.strip() data = json.loads(text) answers = data.get("answers", data) if isinstance(data, dict) else data # Match by item_number or by question_text fallback by_item = {} for a in answers: item = str(a.get("item_number") or "").strip() if item: by_item[item] = a for q in questions: key = str(q.get("item_number") or "").strip() ans = by_item.get(key) if not ans: # fallback: positional match if all numbers missing continue correct_text = (ans.get("correct_answer") or "").strip() if correct_text: # Find best-matching option text (case-insensitive contains) opts = q.get("options") or [] matched = None for opt in opts: if opt.strip().lower() == correct_text.lower(): matched = opt break if not matched: for opt in opts: if correct_text.lower() in opt.lower() or opt.lower() in correct_text.lower(): matched = opt break q["correct_answer"] = matched or correct_text q["explanation"] = (ans.get("explanation") or "").strip() return questions except Exception as e: logger.warning(f"ai_answer attempt {attempt + 1}: {e}") # If all attempts fail, return questions as-is (PENDING) return questions # ─── TWO-STEP (SEPARATE ANSWER KEY) ───────────────────────────────────────── def extract_two_step( document_id: int, section_start: int, section_end: int, model_id: str | None, api_key: str | None, push_step, # callable(step, message) to report progress chunk_pages: int = 50, ) -> tuple[list[dict], list[str]]: """ Two-phase extraction for PDFs with questions in the first half and a separate answer key section (e.g. PREP 2013 "Preferred Response:"). Returns (valid_questions, skipped_list). Raises ValueError if answer section not found or no questions matched. """ total = section_end - section_start + 1 min_answer_start = section_start + max(20, int(total * 0.20)) # Scan for answer key boundary push_step("ai", "Two-step mode: scanning for answer key section…") answer_section_start = None for scan_p in range(min_answer_start, section_end, 10): raw = vector_service.get_pages_text(document_id=document_id, start_page=scan_p, end_page=min(scan_p + 9, section_end)) if raw and ("Preferred Response:" in _normalize(raw) or "preferred response:" in raw.lower()): answer_section_start = max(section_start, scan_p - 5) break if not answer_section_start: raise ValueError( "Two-step mode: could not find a separate answer key section " "(no 'Preferred Response:' found after the first 20% of pages). " "Try 'Standard' mode instead." ) push_step("ai", f"Answer key section starts around page {answer_section_start}.") q_end = answer_section_start - 1 q_chunks = [] p = section_start while p <= q_end: end = min(p + chunk_pages - 1, q_end) q_chunks.append((p, end)) p = end + 1 n = len(q_chunks) # Phase 1 — questions raw_questions: list[dict] = [] for i, (sp, ep) in enumerate(q_chunks, 1): push_step("ai", f"Phase 1 – Chunk {i}/{n}: pages {sp}–{ep} (questions)…") chunk = vector_service.get_pages_text(document_id=document_id, start_page=sp, end_page=ep) if not chunk: continue try: qs = ai_service.extract_questions_no_answers( _normalize(chunk), page_info=f"{sp}-{ep}", page_ref=sp, model_id=model_id, api_key=api_key, ) raw_questions.extend(qs) push_step("ai", f" Pages {sp}–{ep}: {len(qs)} questions. Total: {len(raw_questions)}.") except Exception as e: push_step("ai", f" Pages {sp}–{ep} failed: {e}. Continuing…") # Phase 2 — answer key push_step("ai", f"Phase 2 – Answer key from pages {answer_section_start}–{section_end}…") answer_key: dict = {} for ans_start in range(answer_section_start, section_end + 1, chunk_pages): ans_end = min(ans_start + chunk_pages - 1, section_end) ans_content = vector_service.get_pages_text(document_id=document_id, start_page=ans_start, end_page=ans_end) if not ans_content: continue chunk_key = ai_service.extract_answer_key( _normalize(ans_content), page_info=f"{ans_start}-{ans_end}", model_id=model_id, api_key=api_key, ) answer_key.update(chunk_key) push_step("ai", f" Answer pages {ans_start}–{ans_end}: +{len(chunk_key)}. Total: {len(answer_key)}.") push_step("ai", f"Answer key complete: {len(answer_key)} items.") # Phase 3 — match push_step("ai", "Phase 3 – Matching questions to answers…") valid: list[dict] = [] skipped: list[str] = [] for q in raw_questions: item = q.get("item_number") letter = answer_key.get(item) if item else None if not letter: skipped.append(q.get("question_text", "")[:120]) continue options = q.get("options") or [] idx = ord(letter.upper()) - ord("A") if 0 <= idx < len(options): q["correct_answer"] = options[idx] valid.append(q) else: skipped.append(q.get("question_text", "")[:120]) push_step("ai", f"Matching: {len(valid)} matched, {len(skipped)} unmatched.") return valid, skipped # ─── REGEX MODE ────────────────────────────────────────────────────────────── REGEX_ANALYSIS_PROMPT = """Look at this PREP exam PDF content and identify the pattern used to mark correct answers. Describe: 1. The exact text pattern before the correct answer letter (e.g. "Correct Answer:" or "Preferred Response:") 2. Whether answers appear right after each question (inline) or in a separate section at the back 3. A Python regex pattern that would match: the answer indicator + whitespace + the letter (A-E) Return ONLY JSON: {{"indicator": "", "placement": "inline" | "end_of_doc", "regex": "", "notes": ""}} Sample content (first 30 pages): {content}""" def extract_with_regex( document_id: int, section_start: int, section_end: int, model_id: str | None, api_key: str | None, push_step, chunk_pages: int = 50, ) -> tuple[list[dict], list[str]]: """AI analyses format, generates regex for answer extraction, then applies it.""" # Sample first 30 pages for analysis push_step("ai", "Regex mode: analysing document format…") sample = vector_service.get_pages_text(document_id=document_id, start_page=section_start, end_page=min(section_start + 29, section_end)) if not sample: raise ValueError("No content found for format analysis") prompt = REGEX_ANALYSIS_PROMPT.format(content=_normalize(sample)[:60000]) analysis_text = ai_service._call_model(prompt, model_id, api_key).strip() if analysis_text.startswith("```"): analysis_text = analysis_text.split("\n", 1)[1] if "\n" in analysis_text else analysis_text[3:] if analysis_text.endswith("```"): analysis_text = analysis_text[:-3] analysis = json.loads(analysis_text.strip()) regex_pattern = analysis.get("regex", r"(?:Correct Answer|Preferred Response)\s*[:\.]?\s*([A-E])") placement = analysis.get("placement", "inline") push_step("ai", f"Format detected: {placement} answers. Indicator: {analysis.get('indicator','?')!r}. Regex: {regex_pattern!r}") # Extract questions using standard no-answer prompt push_step("ai", "Extracting questions…") q_end = section_end if placement == "end_of_doc": # Try to find boundary for scan_p in range(section_start + 20, section_end, 10): raw = vector_service.get_pages_text(document_id=document_id, start_page=scan_p, end_page=min(scan_p + 9, section_end)) if raw: norm = _normalize(raw) try: if re.search(regex_pattern, norm, re.IGNORECASE): q_end = max(section_start, scan_p - 5) break except re.error: break raw_questions: list[dict] = [] q_chunks = [] p = section_start while p <= q_end: q_chunks.append((p, min(p + chunk_pages - 1, q_end))) p += chunk_pages for i, (sp, ep) in enumerate(q_chunks, 1): push_step("ai", f"Questions chunk {i}/{len(q_chunks)}: pages {sp}–{ep}…") chunk = vector_service.get_pages_text(document_id=document_id, start_page=sp, end_page=ep) if not chunk: continue try: qs = ai_service.extract_questions_no_answers( _normalize(chunk), page_info=f"{sp}-{ep}", page_ref=sp, model_id=model_id, api_key=api_key, ) raw_questions.extend(qs) except Exception as e: push_step("ai", f" Chunk {i} failed: {e}") push_step("ai", f"{len(raw_questions)} questions extracted. Applying regex to full document for answers…") # Apply regex to full document to find item→letter mapping answer_map: dict = {} for ans_start in range(section_start, section_end + 1, chunk_pages): ans_end = min(ans_start + chunk_pages - 1, section_end) ans_content = vector_service.get_pages_text(document_id=document_id, start_page=ans_start, end_page=ans_end) if not ans_content: continue norm = _normalize(ans_content) # Find Item NNN + answer pattern combined = r"Item\s+(\d+)[\s\S]{0,300}?" + regex_pattern try: for m in re.finditer(combined, norm, re.IGNORECASE): item_num = m.group(1).lstrip("0") or m.group(1) letter = m.group(2).upper() if len(m.groups()) > 1 else "" if letter: answer_map[item_num] = letter except re.error: pass push_step("ai", f"Regex found {len(answer_map)} item→answer mappings.") # Match valid: list[dict] = [] skipped: list[str] = [] for q in raw_questions: item = q.get("item_number") letter = answer_map.get(item) if item else None if not letter: skipped.append(q.get("question_text", "")[:120]) continue options = q.get("options") or [] idx = ord(letter.upper()) - ord("A") if 0 <= idx < len(options): q["correct_answer"] = options[idx] valid.append(q) else: skipped.append(q.get("question_text", "")[:120]) push_step("ai", f"Matched {len(valid)}, skipped {len(skipped)}.") return valid, skipped # ─── AI DECIDES ────────────────────────────────────────────────────────────── AI_DECIDE_PROMPT = """You are analysing a medical study PDF to determine the best extraction strategy. Section spans pages {start_page}-{end_page} ({total_pages} pages total). Samples from several points across the section so you can see the overall structure: [BEGIN — pages {start_page}-{s1_end}] {sample_start} [MIDDLE — pages {s2_start}-{s2_end}] {sample_middle_1} [MIDDLE — pages {s3_start}-{s3_end}] {sample_middle_2} [END — pages {s4_start}-{end_page}] {sample_end} Based on these samples taken from across the document, which extraction strategy should be used? 1. standard — "Correct Answer: X" or "Preferred Response: X" appears right after each question 2. two_step — questions come first (no answers inline), then a separate answer key section at the back 3. ai_answer — document has Q&A format but NO answers anywhere; AI will pick the correct option and write an explanation Return ONLY JSON: {{"strategy": "standard" | "two_step" | "ai_answer", "reasoning": ""}}""" # ─── GENERATE FROM TEXT ────────────────────────────────────────────────────── GENERATE_PROMPT = """You are a pediatric medical education expert. Read the text below and generate {n} high-quality multiple-choice questions to test comprehension. Rules: - The CORRECT answer must be directly supported by the text — do not invent facts - Create 3 plausible but incorrect distractors using your medical knowledge - Questions should test understanding of concepts, not just exact word recall - Prefer clinical application questions over pure recall when the content allows - Include a 1-2 sentence explanation that cites the key concept from the text - Spread questions across different parts of the text, not just the first section - Each option should be a complete, standalone phrase (not "A", "B" labels) Return ONLY valid JSON (no markdown, no preamble): {{"questions": [ {{ "question_text": "", "question_type": "mcq", "options": ["", "", "", ""], "correct_answer": "", "explanation": "<1-2 sentence explanation>", "page_reference": {page_ref} }} ]}} Text (pages {page_info}): {content}""" QUESTIONS_PER_CHUNK = 8 # target questions per ~50-page chunk def generate_from_text(content: str, page_info: str, page_ref: int | None, model_id: str | None, api_key: str | None, n: int = QUESTIONS_PER_CHUNK) -> list[dict]: """Generate MCQ questions from plain text — correct answers from the document, distractors from AI medical knowledge.""" content = ai_service._truncate_content(content) prompt = GENERATE_PROMPT.format( content=content, page_info=page_info, page_ref=page_ref if page_ref is not None else "null", n=n, ) for attempt in range(3): try: text = ai_service._call_model(prompt, model_id, api_key).strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] text = text.strip() data = json.loads(text) qs = data.get("questions", data) if isinstance(data, dict) else data result = [] for q in qs: if not q.get("question_text") or not q.get("correct_answer"): continue options = q.get("options") or [] correct = q["correct_answer"] # Validate correct_answer is among options if options and correct not in options: # Try to find closest match matches = [o for o in options if correct.lower() in o.lower() or o.lower() in correct.lower()] if matches: correct = matches[0] else: continue # skip malformed question result.append({ "question_text": q["question_text"], "question_type": q.get("question_type", "mcq"), "options": options, "correct_answer": correct, "explanation": q.get("explanation", ""), "page_reference": q.get("page_reference"), "item_number": None, }) if result: return result raise ValueError("No valid questions generated") except Exception as e: logger.warning(f"generate_from_text attempt {attempt + 1}: {e}") raise RuntimeError("generate_from_text failed after 3 attempts") # ─── AI DECIDES ────────────────────────────────────────────────────────────── def ai_decide_strategy( document_id: int, section_start: int, section_end: int, model_id: str | None, api_key: str | None, ) -> str: """AI samples across the section (start, two middle points, end) to decide extraction strategy. For large documents this gives a much better view than just start+end.""" total = max(1, section_end - section_start + 1) sample_size = min(15, max(6, total // 15)) # 6-15 pages per sample # Evenly spaced sample windows across the section s1_start = section_start s1_end = min(section_start + sample_size - 1, section_end) s2_start = min(section_start + total // 3, section_end - sample_size + 1) s2_end = min(s2_start + sample_size - 1, section_end) s3_start = min(section_start + (2 * total) // 3, section_end - sample_size + 1) s3_end = min(s3_start + sample_size - 1, section_end) s4_start = max(section_start, section_end - sample_size + 1) s4_end = section_end def _page_text(a, b): return _normalize(vector_service.get_pages_text(document_id=document_id, start_page=a, end_page=b) or "") sample_start = _page_text(s1_start, s1_end)[:12000] sample_middle_1 = _page_text(s2_start, s2_end)[:12000] sample_middle_2 = _page_text(s3_start, s3_end)[:12000] sample_end = _page_text(s4_start, s4_end)[:12000] prompt = AI_DECIDE_PROMPT.format( start_page=section_start, end_page=section_end, total_pages=total, s1_end=s1_end, s2_start=s2_start, s2_end=s2_end, s3_start=s3_start, s3_end=s3_end, s4_start=s4_start, sample_start=sample_start, sample_middle_1=sample_middle_1, sample_middle_2=sample_middle_2, sample_end=sample_end, ) try: text = ai_service._call_model(prompt, model_id, api_key).strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] result = json.loads(text.strip()) strategy = result.get("strategy", "standard") reasoning = result.get("reasoning", "") logger.info(f"AI decided: {strategy} — {reasoning}") return strategy, reasoning except Exception as e: logger.warning(f"ai_decide failed: {e}, falling back to standard") return "standard", "Fallback to standard due to analysis error" # ─── FLASHCARD GENERATION ──────────────────────────────────────────────────── FLASHCARD_PROMPT = """You are a pediatric medical education expert. Read the text below and create {n} high-quality flashcards for studying. Each flashcard has a FRONT (question, term, or concept prompt) and a BACK (answer, definition, or explanation). Rules: - Mix question-style fronts ("What is the most common cause of...") and term-style fronts ("Hyperbilirubinemia") - FRONT should be concise — one sentence or a few words - BACK should be complete but not verbose — 1-3 sentences with the key facts - Focus on high-yield facts: diagnostic criteria, treatment protocols, age-specific norms, pathophysiology - Do NOT repeat the same concept in multiple cards - Spread cards across different parts of the text - Each card must be directly supported by the text — do not invent facts Return ONLY valid JSON (no markdown, no preamble): {{"cards": [ {{ "front": "", "back": "", "page_reference": {page_ref} }} ]}} Text (pages {page_info}): {content}""" FLASHCARDS_PER_CHUNK = 15 def generate_flashcards(content: str, page_info: str, page_ref: int | None, model_id: str | None, api_key: str | None, n: int = FLASHCARDS_PER_CHUNK) -> list[dict]: """Generate flashcards from text content using AI.""" content = ai_service._truncate_content(content) prompt = FLASHCARD_PROMPT.format( content=content, page_info=page_info, page_ref=page_ref if page_ref else "null", n=n, ) for attempt in range(3): try: text = ai_service._call_model(prompt, model_id, api_key).strip() if text.startswith("```"): text = text.split("\n", 1)[1] if "\n" in text else text[3:] if text.endswith("```"): text = text[:-3] text = text.strip() data = json.loads(text) cards = data.get("cards", data) if isinstance(data, dict) else data result = [] for c in cards: if not c.get("front") or not c.get("back"): continue result.append({ "front": c["front"].strip(), "back": c["back"].strip(), "page_reference": c.get("page_reference"), }) return result except (json.JSONDecodeError, KeyError) as e: logger.warning(f"Flashcard generation attempt {attempt + 1} failed: {e}") continue return []