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) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-19 20:17:49 +02:00
parent 5398342e3d
commit 2a0dd56f95
5 changed files with 232 additions and 20 deletions

View file

@ -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,

View file

@ -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": "<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(
@ -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": "<one sentence>"}}"""
@ -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()

View file

@ -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(

View file

@ -405,6 +405,7 @@ export default function DocumentDetailPage() {
<select value={extractionMode} onChange={e => setExtractionMode(e.target.value)}>
<option value="standard">Standard inline answers (Correct Answer / Preferred Response)</option>
<option value="questions_only">Questions Only no answers (fill in manually later)</option>
<option value="ai_answer">AI Answer extract questions, AI determines correct answers</option>
<option value="two_step">Two-Step separate answer key section (PREP 2013 style)</option>
<option value="regex">AI + Regex AI analyses format then applies regex for answers</option>
<option value="ai_decide">AI Decides AI reads the document and picks best strategy</option>
@ -413,9 +414,10 @@ export default function DocumentDetailPage() {
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
{extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'}
{extractionMode === 'questions_only' && 'Extracts questions + options only. Answer each question manually in Edit mode.'}
{extractionMode === 'ai_answer' && 'For Q&A PDFs with no answer key. AI extracts questions then determines the correct answer and explanation from document context + medical knowledge.'}
{extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'}
{extractionMode === 'regex' && 'AI detects the answer pattern, then uses regex for fast reliable extraction.'}
{extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy.'}
{extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy (standard, two_step, or ai_answer).'}
{extractionMode === 'generate' && 'For textbook chapters, lecture notes, or any material without a Q&A format. AI creates MCQ questions with correct answers from the text.'}
</p>
</div>

View file

@ -37,6 +37,8 @@ export default function FlashcardsPage() {
const [filterDeckId, setFilterDeckId] = useState('')
const [studyCard, setStudyCard] = useState(null)
const [flipped, setFlipped] = useState(false)
const [editingDeckId, setEditingDeckId] = useState(null)
const [editingTitle, setEditingTitle] = useState('')
const [deletingCard, setDeletingCard] = useState(null)
const { user } = useAuth()
const navigate = useNavigate()
@ -111,6 +113,15 @@ export default function FlashcardsPage() {
} catch { }
}
const saveTitle = async (deckId, newTitle) => {
const t = (newTitle || '').trim()
if (!t) return
try {
await api.patch(`/flashcards/${deckId}`, { title: t })
setDecks(prev => prev.map(d => d.id === deckId ? { ...d, title: t } : d))
} catch { }
}
const toggleShare = async (deckId) => {
try {
const res = await api.put(`/flashcards/${deckId}/share`)
@ -189,7 +200,34 @@ export default function FlashcardsPage() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{decks.map(deck => (
<div key={deck.id} className="card" style={{ padding: 20 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 6 }}>{deck.title}</h3>
{editingDeckId === deck.id ? (
<div style={{ marginBottom: 8 }}>
<input
type="text"
value={editingTitle}
onChange={e => 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 }}
/>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button className="btn btn-primary btn-sm" onClick={() => { saveTitle(deck.id, editingTitle); setEditingDeckId(null) }}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditingDeckId(null)}>Cancel</button>
</div>
</div>
) : (
<h3 style={{ fontSize: '1rem', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{deck.title}</span>
<button
onClick={() => { setEditingDeckId(deck.id); setEditingTitle(deck.title) }}
title="Rename deck"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.85rem', padding: '0 4px', flexShrink: 0 }}
></button>
</h3>
)}
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 4 }}>
{deck.card_count} cards &middot; {new Date(deck.created_at).toLocaleDateString()}
</p>