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:
parent
5398342e3d
commit
2a0dd56f95
5 changed files with 232 additions and 20 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 · {new Date(deck.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue