From 2a0dd56f95611bc01b0f0e4a0644f6a49c89ca7c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 19 Apr 2026 20:17:49 +0200 Subject: [PATCH] Add ai_answer extraction + broader ai_decide sampling + flashcard title edit ai_decide now samples 4 points across the section (start, 1/3, 2/3, end) instead of just the first 30 + last 20 pages. This gives accurate strategy detection on large documents where the answer format might be deeper in. New ai_answer extraction mode: - Extracts questions from Q&A-format PDFs that have no answer key - AI picks the correct option from each question's choices - Generates explanation using document context + medical knowledge - Useful for PDFs like practice tests where answers were never included - Available manually and as an ai_decide strategy Flashcard decks can now be renamed: - PATCH /flashcards/{deck_id} updates title - Inline edit on FlashcardsPage with responsive layout (input full-width, buttons wrap under it so Cancel never overflows the card) - Title truncates with ellipsis when not editing Note: generate mode (textbook -> MCQs) is unchanged per user request. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/flashcards.py | 23 ++++ backend/app/services/extraction_modes.py | 161 +++++++++++++++++++--- backend/app/tasks/quiz_tasks.py | 24 ++++ frontend/src/pages/DocumentDetailPage.jsx | 4 +- frontend/src/pages/FlashcardsPage.jsx | 40 +++++- 5 files changed, 232 insertions(+), 20 deletions(-) diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index 395e970..d92864d 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -23,6 +23,10 @@ class FlashcardDeckCreate(BaseModel): model_id: str | None = None +class FlashcardDeckUpdate(BaseModel): + title: str | None = None + + class FlashcardDeckResponse(BaseModel): id: int title: str @@ -256,6 +260,25 @@ def restore_flashcard_deck( # ── Sharing & rating ──────────────────────────────────────────────── +@router.patch("/{deck_id}") +def update_deck( + deck_id: int, + data: FlashcardDeckUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update deck metadata (title). Owner or admin only.""" + deck = _own_deck_or_404(deck_id, current_user, db) + if data.title is not None: + title = data.title.strip() + if not title: + raise HTTPException(status_code=400, detail="Title cannot be empty") + deck.title = title[:300] + db.commit() + db.refresh(deck) + return {"id": deck.id, "title": deck.title} + + @router.put("/{deck_id}/share") def toggle_share_deck( deck_id: int, diff --git a/backend/app/services/extraction_modes.py b/backend/app/services/extraction_modes.py index 576fb9c..4bfd956 100644 --- a/backend/app/services/extraction_modes.py +++ b/backend/app/services/extraction_modes.py @@ -90,6 +90,98 @@ def extract_questions_only(content: str, page_info: str, page_ref: int | None, 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( @@ -330,23 +422,32 @@ def extract_with_regex( # ─── AI DECIDES ────────────────────────────────────────────────────────────── -AI_DECIDE_PROMPT = """You are analysing a PREP medical exam PDF to determine the best extraction strategy. +AI_DECIDE_PROMPT = """You are analysing a medical study PDF to determine the best extraction strategy. -Sample from first 30 pages: +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} ---- -Sample from last 20 pages: +[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, which extraction strategy should be used? +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), then a separate answer key section at the back -3. questions_only — no answer indicators at all (answers unknown) +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" | "questions_only", +{{"strategy": "standard" | "two_step" | "ai_answer", "reasoning": ""}}""" @@ -443,16 +544,40 @@ def ai_decide_strategy( model_id: str | None, api_key: str | None, ) -> str: - """AI reads samples from start and end of document and decides extraction strategy.""" - sample_start = vector_service.get_pages_text(document_id=document_id, - start_page=section_start, - end_page=min(section_start + 29, section_end)) - sample_end = vector_service.get_pages_text(document_id=document_id, - start_page=max(section_start, section_end - 19), - end_page=section_end) + """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( - sample_start=_normalize(sample_start or "")[:30000], - sample_end=_normalize(sample_end or "")[:20000], + 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() diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index e10d5b4..46d365e 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -107,6 +107,7 @@ def extract_quiz( from app.services.extraction_modes import ( extract_questions_only, extract_two_step, extract_with_regex, ai_decide_strategy, generate_from_text, + ai_answer_questions, ) resolved_mode = extraction_mode @@ -138,6 +139,29 @@ def extract_quiz( except Exception as e: _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}") + elif resolved_mode == "ai_answer": + _push_step(r, job_id, "ai", "Mode: AI Answer — extracting questions and using AI to determine correct answers.") + for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): + if r.get(f"extraction:status:{job_id}") == "cancelled": + _push_step(r, job_id, "cancelled", "Job cancelled.") + return + _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} — extracting questions…") + chunk_content = vector_service.get_pages_text( + document_id=section.document_id, start_page=start_p, end_page=end_p) + if not chunk_content: + continue + try: + normalized = _normalize_ocr(chunk_content) + qs = extract_questions_only(normalized, f"{start_p}-{end_p}", start_p, model_id, api_key) + if qs: + _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p}: {len(qs)} questions found, AI determining answers…") + qs = ai_answer_questions(qs, normalized, f"{start_p}-{end_p}", model_id, api_key) + answered = sum(1 for q in qs if q.get("correct_answer") and q["correct_answer"] != "PENDING") + all_valid_questions.extend(qs) + _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p}: {answered}/{len(qs)} answered. Total: {len(all_valid_questions)}.") + except Exception as e: + _push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}") + elif resolved_mode == "two_step": _push_step(r, job_id, "ai", "Mode: Two-Step (separate answer key section).") all_valid_questions, all_skipped = extract_two_step( diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index 5a8e72b..05cb0ba 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -405,6 +405,7 @@ export default function DocumentDetailPage() { setEditingTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { saveTitle(deck.id, editingTitle); setEditingDeckId(null) } + if (e.key === 'Escape') setEditingDeckId(null) + }} + autoFocus + style={{ width: '100%', boxSizing: 'border-box', padding: '6px 10px', fontSize: '1rem', fontWeight: 600, border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 8 }} + /> +
+ + +
+ + ) : ( +

+ {deck.title} + +

+ )}

{deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()}