"PREP" is the American Academy of Pediatrics' trademark for their own product. The plans here are our own sets of questions grouped by year, so they are now named for what they are: Board Review 2021, and Mixed Review for the plan that draws from every year at once. Renamed in the database as well as the code — 13 plans, 14 quizzes a learner had already generated from a block, and the 12 year tags, which appear in the question bank's filters and are as visible as the plans. The seeder matches both the old and new names so a fresh import still finds its material, and the tagger mints the new one so the next run cannot undo this. Prompts and comments that described the source PDFs by that name now describe them by what they are. The generation run's 377 failures were not a bug Every call was reserving the model's full 64k output ceiling, and OpenRouter refuses the whole request when the balance is below the reservation — "you requested up to 64000 tokens, but can only afford 52017" — however short the answer would actually be. `_call_model` now takes a max_tokens, and the article writer asks for 4000, which is comfortable for three views of one topic and keeps each request small enough to be affordable. 98 articles were written before the balance ran down; 158 exist in total. Generation is paused at the user's request while credits are topped up. 208 backend, 243 frontend green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
660 lines
28 KiB
Python
660 lines
28 KiB
Python
"""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 (2013-style): 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 board review exam content.
|
||
Do NOT look for correct answers — we only need the question text and answer options.
|
||
|
||
Return ONLY JSON:
|
||
{{"questions": [
|
||
{{
|
||
"item_number": "<digits only, or null>",
|
||
"question_text": "<full vignette + stem>",
|
||
"question_type": "mcq",
|
||
"options": ["<A>", "<B>", "<C>", "<D>", "<E>"],
|
||
"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": "<matches input>",
|
||
"correct_answer": "<exact text of one of the given options>",
|
||
"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. a 2013-style "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 board review 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": "<text before letter>",
|
||
"placement": "inline" | "end_of_doc",
|
||
"regex": "<python regex with one capture group for the letter>",
|
||
"notes": "<any relevant observation>"}}
|
||
|
||
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": "<one sentence>"}}"""
|
||
|
||
|
||
# ─── 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": "<clear clinical or conceptual question>",
|
||
"question_type": "mcq",
|
||
"options": ["<correct answer text>", "<distractor B>", "<distractor C>", "<distractor D>"],
|
||
"correct_answer": "<exact text of the correct option>",
|
||
"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": "<question or term>",
|
||
"back": "<answer or definition>",
|
||
"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 []
|