Add AI-powered flashcard generation system

New feature: generate flashcards from PDF sections using AI, completely
separate from the existing quiz system.

Backend:
- FlashcardDeck + Flashcard models with cascade deletes
- flashcard_tag_links table for tag classification (reuses question_tags)
- /api/flashcards/ router: CRUD for decks, browse/search cards, tag filtering
- generate_flashcard_deck Celery task with chunked processing + progress
- FLASHCARD_PROMPT in extraction_modes.py (15 cards per chunk)
- "flashcard" added to admin model task types

Frontend:
- FlashcardsPage: deck grid + card browser with search/filter
- FlashcardStudyPage: flip cards, mark known/review, keyboard nav,
  shuffle, progress bar, completion screen
- DocumentDetailPage: "Create Flashcards" button alongside "Extract Quiz"
- Navbar: Flashcards link
- AdminPage: flashcard in model task dropdown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 22:59:21 +02:00
parent 2cbbfe00c3
commit 3e37bf2128
13 changed files with 967 additions and 4 deletions

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings from app.config import settings
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler from app.utils.scheduler import start_scheduler, stop_scheduler
@ -131,6 +131,7 @@ def setup_pgvector():
from sqlalchemy import text from sqlalchemy import text
# Import new models so create_all picks them up # Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
from app.models import flashcard # noqa
# Kill stale idle-in-transaction connections from previous killed startups. # Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely. # They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
@ -275,6 +276,35 @@ def setup_pgvector():
PRIMARY KEY (question_id, tag_id) PRIMARY KEY (question_id, tag_id)
) )
""")) """))
# Flashcard decks and cards
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_decks (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
card_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcards (
id SERIAL PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
front TEXT NOT NULL,
back TEXT NOT NULL,
page_reference INTEGER,
image_path VARCHAR,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_tag_links (
flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES question_tags(id) ON DELETE CASCADE,
PRIMARY KEY (flashcard_id, tag_id)
)
"""))
conn.commit() conn.commit()
@ -380,6 +410,7 @@ app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]
app.include_router(teach.router, prefix="/api/teach", tags=["teach"]) app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"]) app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"]) app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class FlashcardDeck(Base):
__tablename__ = "flashcard_decks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
card_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
user = relationship("User")
class Flashcard(Base):
__tablename__ = "flashcards"
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
front = Column(Text, nullable=False)
back = Column(Text, nullable=False)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="cards")

View file

@ -187,7 +187,7 @@ def create_model(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
if data.task not in ("extraction", "tts", "teach", "keyword"): if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword") raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword")
if data.is_default: if data.is_default:

View file

@ -0,0 +1,316 @@
"""Flashcard decks and cards — generate, browse, edit, delete."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import or_, text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.flashcard import FlashcardDeck, Flashcard
from app.models.section import Section
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────
class FlashcardDeckCreate(BaseModel):
section_id: int
title: str
model_id: str | None = None
class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None
user_id: int
card_count: int
created_at: object
class Config:
from_attributes = True
class FlashcardCardResponse(BaseModel):
id: int
deck_id: int
front: str
back: str
page_reference: int | None = None
image_path: str | None = None
created_at: object
class Config:
from_attributes = True
class FlashcardDeckDetail(BaseModel):
id: int
title: str
section_id: int | None
user_id: int
card_count: int
created_at: object
cards: list[FlashcardCardResponse]
class Config:
from_attributes = True
class CardEdit(BaseModel):
front: str | None = None
back: str | None = None
# ── Deck endpoints ───────────────────────────────────────────────────
@router.post("/")
def create_flashcard_deck(
data: FlashcardDeckCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Start async flashcard generation from a section. Returns {job_id} immediately."""
import uuid
section = db.query(Section).filter(Section.id == data.section_id).first()
if not section:
raise HTTPException(status_code=404, detail="Section not found")
if not current_user.is_admin and section.document.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your document")
job_id = str(uuid.uuid4())
try:
from app.tasks.quiz_tasks import generate_flashcard_deck
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
r.set(f"extraction:job_title:{job_id}", data.title, ex=3600)
generate_flashcard_deck.delay(
job_id=job_id,
user_id=current_user.id,
section_id=data.section_id,
title=data.title,
model_id=data.model_id,
)
except Exception:
raise HTTPException(status_code=503, detail="Task queue unavailable")
return {"job_id": job_id, "status": "pending"}
@router.get("/", response_model=list[FlashcardDeckResponse])
def list_flashcard_decks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List flashcard decks. Moderators see all; regular users see own."""
q = db.query(FlashcardDeck)
if not current_user.is_moderator:
q = q.filter(FlashcardDeck.user_id == current_user.id)
return q.order_by(FlashcardDeck.created_at.desc()).all()
@router.get("/{deck_id}", response_model=FlashcardDeckDetail)
def get_flashcard_deck(
deck_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a deck with all its cards."""
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if not current_user.is_moderator and deck.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your deck")
return deck
@router.delete("/{deck_id}", status_code=204)
def delete_flashcard_deck(
deck_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete a deck and all its cards."""
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
db.delete(deck)
db.commit()
# ── Card browse endpoints ────────────────────────────────────────────
@router.get("/cards/browse")
def browse_flashcards(
q: str | None = Query(None),
deck_id: int | None = Query(None),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Browse ALL flashcards across all decks (like question bank)."""
query = db.query(Flashcard)
# Restrict non-moderators to own decks
if not current_user.is_moderator:
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
FlashcardDeck.user_id == current_user.id
).all()]
if not own_deck_ids:
return {"total": 0, "cards": []}
query = query.filter(Flashcard.deck_id.in_(own_deck_ids))
if deck_id:
query = query.filter(Flashcard.deck_id == deck_id)
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Flashcard.front.ilike(f"%{phrase}%"),
Flashcard.back.ilike(f"%{phrase}%"),
)
)
# Tag filter: cards must have ALL specified tags
if tag_ids:
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
if tag_id_list:
matching_ids = list(db.execute(sa_text("""
SELECT flashcard_id FROM flashcard_tag_links
WHERE tag_id = ANY(:tag_ids)
GROUP BY flashcard_id
HAVING COUNT(DISTINCT tag_id) = :cnt
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
if matching_ids:
query = query.filter(Flashcard.id.in_(matching_ids))
else:
return {"total": 0, "cards": []}
total = query.count()
cards = query.order_by(Flashcard.deck_id, Flashcard.id).offset(offset).limit(limit).all()
deck_cache: dict[int, str] = {}
result = []
for card in cards:
if card.deck_id not in deck_cache:
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
deck_cache[card.deck_id] = deck.title if deck else f"Deck {card.deck_id}"
result.append({
"id": card.id,
"deck_id": card.deck_id,
"deck_title": deck_cache[card.deck_id],
"front": card.front,
"back": card.back,
"page_reference": card.page_reference,
"image_path": card.image_path,
"created_at": card.created_at,
})
return {"total": total, "cards": result}
@router.get("/cards/browse/ids")
def browse_flashcard_ids(
q: str | None = Query(None),
deck_id: int | None = Query(None),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return just IDs for matching cards (for select-all)."""
query = db.query(Flashcard.id)
if not current_user.is_moderator:
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
FlashcardDeck.user_id == current_user.id
).all()]
if not own_deck_ids:
return []
query = query.filter(Flashcard.deck_id.in_(own_deck_ids))
if deck_id:
query = query.filter(Flashcard.deck_id == deck_id)
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Flashcard.front.ilike(f"%{phrase}%"),
Flashcard.back.ilike(f"%{phrase}%"),
)
)
if tag_ids:
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
if tag_id_list:
matching_ids = list(db.execute(sa_text("""
SELECT flashcard_id FROM flashcard_tag_links
WHERE tag_id = ANY(:tag_ids)
GROUP BY flashcard_id
HAVING COUNT(DISTINCT tag_id) = :cnt
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
if matching_ids:
query = query.filter(Flashcard.id.in_(matching_ids))
else:
return []
return [row[0] for row in query.all()]
# ── Single card endpoints ────────────────────────────────────────────
@router.put("/cards/{card_id}")
def update_flashcard(
card_id: int,
data: CardEdit,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Edit a single flashcard (front, back). Requires moderator."""
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
if data.front is not None:
card.front = data.front
if data.back is not None:
card.back = data.back
db.commit()
db.refresh(card)
return {
"id": card.id,
"deck_id": card.deck_id,
"front": card.front,
"back": card.back,
"page_reference": card.page_reference,
"image_path": card.image_path,
}
@router.delete("/cards/{card_id}", status_code=204)
def delete_flashcard(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete a single flashcard. Requires moderator."""
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
db.delete(card)
if deck and deck.card_count > 0:
deck.card_count -= 1
db.commit()

View file

@ -0,0 +1,29 @@
from datetime import datetime
from pydantic import BaseModel
class FlashcardDeckCreate(BaseModel):
section_id: int
title: str
model_id: str | None = None
class FlashcardResponse(BaseModel):
id: int
front: str
back: str
page_reference: int | None = None
image_path: str | None = None
class Config:
from_attributes = True
class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None = None
user_id: int
card_count: int
created_at: datetime
class Config:
from_attributes = True
class FlashcardDeckDetail(FlashcardDeckResponse):
cards: list[FlashcardResponse] = []

View file

@ -467,3 +467,69 @@ def ai_decide_strategy(
except Exception as e: except Exception as e:
logger.warning(f"ai_decide failed: {e}, falling back to standard") logger.warning(f"ai_decide failed: {e}, falling back to standard")
return "standard", "Fallback to standard due to analysis error" 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 []

View file

@ -497,3 +497,113 @@ def regenerate_embeddings(self, job_id: str, user_id: int):
raise raise
finally: finally:
db.close() db.close()
@celery_app.task(name="generate_flashcard_deck", bind=True)
def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
title: str, model_id: str | None = None):
"""Generate flashcards from a document section using AI."""
r = _redis()
r.set(f"job:status:{job_id}", "running")
db = SessionLocal()
try:
from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.services import vector_service
from app.services import extraction_modes
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
r.set(f"job:status:{job_id}", "failed")
_push_step(r, job_id, "error", "Section not found")
return
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
from app.services.ai_service import get_model_for_task
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
if model_id:
ai_model_id = model_id
total_pages = section.end_page - section.start_page + 1
_push_step(r, job_id, "start", f"Generating flashcards from {total_pages} pages…")
all_cards = []
if total_pages <= CHUNK_PAGES:
content = vector_service.get_pages_text(section.document_id, section.start_page, section.end_page)
if content:
_push_step(r, job_id, "ai", f"Generating flashcards from pages {section.start_page}{section.end_page}")
cards = extraction_modes.generate_flashcards(
content, f"{section.start_page}{section.end_page}",
section.start_page, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Generated {len(cards)} cards")
else:
n_chunks = (total_pages + CHUNK_PAGES - 1) // CHUNK_PAGES
_push_step(r, job_id, "ai", f"Large section: splitting into {n_chunks} chunks")
for chunk_idx in range(1, n_chunks + 1):
start_p = section.start_page + (chunk_idx - 1) * CHUNK_PAGES
end_p = min(start_p + CHUNK_PAGES - 1, section.end_page)
content = vector_service.get_pages_text(section.document_id, start_p, end_p)
if not content or len(content.strip()) < 100:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: no text, skipping")
continue
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
cards = extraction_modes.generate_flashcards(
content, f"{start_p}{end_p}", start_p, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
if not all_cards:
r.set(f"job:status:{job_id}", "failed")
_push_step(r, job_id, "error", "No flashcards could be generated")
return
# Refresh DB connection for save phase
from sqlalchemy import text as _text
try:
db.execute(_text("SELECT 1"))
except Exception:
db.rollback()
db.close()
db = SessionLocal()
_push_step(r, job_id, "save", f"Saving {len(all_cards)} flashcards…")
from app.models.flashcard import FlashcardDeck, Flashcard
deck = FlashcardDeck(
title=title,
section_id=section_id,
user_id=user_id,
card_count=len(all_cards),
)
db.add(deck)
db.flush()
for c in all_cards:
card = Flashcard(
deck_id=deck.id,
front=c["front"],
back=c["back"],
page_reference=c.get("page_reference"),
)
db.add(card)
db.commit()
r.set(f"job:status:{job_id}", "completed")
r.set(f"job:result:{job_id}", str(deck.id))
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
except Exception as e:
logger.exception(f"Flashcard generation failed: {e}")
r.set(f"job:status:{job_id}", "failed")
r.set(f"job:error:{job_id}", str(e)[:500])
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()

View file

@ -22,6 +22,8 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage'
import NotFoundPage from './pages/NotFoundPage' import NotFoundPage from './pages/NotFoundPage'
import LandingPage from './pages/LandingPage' import LandingPage from './pages/LandingPage'
import FlashcardsPage from './pages/FlashcardsPage'
import FlashcardStudyPage from './pages/FlashcardStudyPage'
// Layout wrapper for authenticated app pages (Navbar + container + footer) // Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() { function AppLayout() {
@ -72,6 +74,8 @@ function AppRoutes() {
<Route path="/account" element={<AccountPage />} /> <Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} /> <Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
</Route> </Route>
</Route> </Route>

View file

@ -92,6 +92,7 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
{ to: '/quizzes', label: 'Quizzes' }, { to: '/quizzes', label: 'Quizzes' },
{ to: '/question-bank', label: 'Question Bank' }, { to: '/question-bank', label: 'Question Bank' },
{ to: '/flashcards', label: 'Flashcards' },
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []), ...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
{ to: '/settings', label: '⚙ Settings' }, { to: '/settings', label: '⚙ Settings' },
] : [] ] : []

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog' import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog' import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword'] const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard']
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuth() const { user } = useAuth()

View file

@ -180,6 +180,29 @@ export default function DocumentDetailPage() {
} }
} }
const generateFlashcards = async (sectionId, sectionName) => {
setGenerating(sectionId)
setError('')
try {
const title = quizTitle || `Flashcards: ${sectionName}`
const res = await api.post('/flashcards/', {
section_id: sectionId,
title,
model_id: selectedModelId || null,
})
if (res.data.job_id) {
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
stored.unshift({ jobId: res.data.job_id, title, status: 'running', lastStep: 'Starting…', ts: Date.now() })
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored.slice(0, 10)))
setActiveJob({ jobId: res.data.job_id, sectionName })
}
} catch (err) {
setError(err.response?.data?.detail || 'Failed to start flashcard generation. Check AI model config.')
} finally {
setGenerating(null)
}
}
const generateQuiz = async (sectionId, sectionName) => { const generateQuiz = async (sectionId, sectionName) => {
setGenerating(sectionId) setGenerating(sectionId)
setError('') setError('')
@ -463,7 +486,14 @@ export default function DocumentDetailPage() {
> >
{generating === section.id ? ( {generating === section.id ? (
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</> <><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract & Create Quiz'} ) : 'Extract Quiz'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => generateFlashcards(section.id, section.name)}
disabled={generating === section.id}
>
Create Flashcards
</button> </button>
<ConfirmButton <ConfirmButton
onConfirm={() => deleteSection(section.id)} onConfirm={() => deleteSection(section.id)}

View file

@ -0,0 +1,188 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../api/client'
export default function FlashcardStudyPage() {
const { deckId } = useParams()
const navigate = useNavigate()
const [deck, setDeck] = useState(null)
const [loading, setLoading] = useState(true)
const [currentIdx, setCurrentIdx] = useState(0)
const [flipped, setFlipped] = useState(false)
const [known, setKnown] = useState(new Set())
const [review, setReview] = useState(new Set())
const [mode, setMode] = useState('all') // 'all' | 'review'
const [shuffled, setShuffled] = useState(false)
const [cardOrder, setCardOrder] = useState([])
useEffect(() => {
api.get(`/flashcards/${deckId}`).then(res => {
setDeck(res.data)
setCardOrder(res.data.cards.map((_, i) => i))
setLoading(false)
}).catch(() => navigate('/flashcards'))
}, [deckId])
const cards = deck?.cards || []
const activeIndices = mode === 'review' ? cardOrder.filter(i => review.has(cards[i]?.id)) : cardOrder
const currentCard = cards[activeIndices[currentIdx]]
const total = activeIndices.length
const progress = total > 0 ? Math.round(((known.size) / cards.length) * 100) : 0
const next = useCallback(() => {
setFlipped(false)
if (currentIdx < total - 1) setCurrentIdx(i => i + 1)
}, [currentIdx, total])
const prev = useCallback(() => {
setFlipped(false)
if (currentIdx > 0) setCurrentIdx(i => i - 1)
}, [currentIdx])
const markKnown = () => {
if (currentCard) {
setKnown(s => { const n = new Set(s); n.add(currentCard.id); return n })
setReview(s => { const n = new Set(s); n.delete(currentCard.id); return n })
}
next()
}
const markReview = () => {
if (currentCard) {
setReview(s => { const n = new Set(s); n.add(currentCard.id); return n })
setKnown(s => { const n = new Set(s); n.delete(currentCard.id); return n })
}
next()
}
const shuffle = () => {
setCardOrder(prev => {
const arr = [...prev]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
})
setCurrentIdx(0)
setFlipped(false)
setShuffled(true)
}
const reset = () => {
setKnown(new Set())
setReview(new Set())
setCurrentIdx(0)
setFlipped(false)
setMode('all')
}
// Keyboard navigation
useEffect(() => {
const handler = (e) => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setFlipped(v => !v) }
if (e.key === 'ArrowRight') next()
if (e.key === 'ArrowLeft') prev()
if (e.key === '1') markKnown()
if (e.key === '2') markReview()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [next, prev, currentCard])
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!deck) return null
const allDone = known.size === cards.length
return (
<div>
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
{cards.length} cards · {known.size} known · {review.size} to review
</p>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button className={`btn btn-sm ${mode === 'all' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('all'); setCurrentIdx(0); setFlipped(false) }}>All ({cards.length})</button>
{review.size > 0 && (
<button className={`btn btn-sm ${mode === 'review' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('review'); setCurrentIdx(0); setFlipped(false) }}>Review ({review.size})</button>
)}
<button className="btn btn-sm btn-secondary" onClick={shuffle}>Shuffle</button>
<button className="btn btn-sm btn-secondary" onClick={reset}>Reset</button>
<button className="btn btn-sm btn-secondary" onClick={() => navigate('/flashcards')}>Back</button>
</div>
</div>
{/* Progress bar */}
<div style={{ marginTop: 12 }}>
<div className="progress-bar" style={{ height: 6 }}>
<div className="fill" style={{ width: `${progress}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>{progress}% mastered</div>
</div>
</div>
{/* Completion screen */}
{allDone && (
<div className="card" style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>&#127881;</div>
<h2>All cards mastered!</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: 16 }}>You marked all {cards.length} cards as known.</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={reset}>Study again</button>
<button className="btn btn-secondary" onClick={() => navigate('/flashcards')}>Back to decks</button>
</div>
</div>
)}
{/* Card */}
{!allDone && total > 0 && currentCard && (
<>
<div
style={{
background: 'var(--card-bg)', borderRadius: 'var(--card-radius)',
padding: 40, minHeight: 250, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', textAlign: 'center',
cursor: 'pointer', border: `2px solid ${flipped ? 'var(--primary)' : 'var(--border)'}`,
boxShadow: '0 4px 20px rgba(0,0,0,0.08)', transition: 'border-color 0.2s',
marginBottom: 16,
}}
onClick={() => setFlipped(v => !v)}
>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 8, textTransform: 'uppercase', fontWeight: 700, letterSpacing: '0.05em' }}>
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
</div>
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
{flipped ? currentCard.back : currentCard.front}
</p>
{!flipped && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
)}
</div>
{/* Controls */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>&#8592; Prev</button>
{flipped && (
<>
<button className="btn btn-primary" onClick={markKnown} style={{ background: '#22c55e', borderColor: '#22c55e' }}>Got it &#10003;</button>
<button className="btn btn-secondary" onClick={markReview} style={{ color: '#ef4444', borderColor: '#ef4444' }}>Review again</button>
</>
)}
<button className="btn btn-secondary" onClick={next} disabled={currentIdx >= total - 1}>Next &#8594;</button>
</div>
<div style={{ textAlign: 'center', marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Keyboard: Space/Enter=flip, &#8592;&#8594;=navigate, 1=got it, 2=review
</div>
</>
)}
{!allDone && total === 0 && (
<div className="card"><div className="empty-state">No cards to show. {mode === 'review' ? 'No cards marked for review.' : ''}</div></div>
)}
</div>
)
}

View file

@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function FlashcardsPage() {
const [tab, setTab] = useState('decks') // 'decks' | 'browse'
const [decks, setDecks] = useState([])
const [cards, setCards] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [filterDeckId, setFilterDeckId] = useState('')
const [offset, setOffset] = useState(0)
const [studyCard, setStudyCard] = useState(null)
const [flipped, setFlipped] = useState(false)
const { user } = useAuth()
const navigate = useNavigate()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = 50
useEffect(() => {
api.get('/flashcards/').then(res => {
setDecks(res.data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => {
setLoading(true)
try {
const params = { limit: LIMIT, offset: off }
if (q.trim()) params.q = q.trim()
if (deckId) params.deck_id = parseInt(deckId)
const res = await api.get('/flashcards/cards/browse', { params })
setCards(off === 0 ? res.data.cards : prev => [...prev, ...res.data.cards])
setTotal(res.data.total)
setOffset(off)
} catch { } finally { setLoading(false) }
}
useEffect(() => {
if (tab === 'browse') loadCards()
}, [tab])
useEffect(() => {
if (tab !== 'browse') return
const t = setTimeout(() => loadCards(searchQuery, 0, filterDeckId), 300)
return () => clearTimeout(t)
}, [searchQuery, filterDeckId])
const deleteDeck = async (deckId) => {
if (!window.confirm('Delete this deck and all its cards?')) return
try {
await api.delete(`/flashcards/${deckId}`)
setDecks(prev => prev.filter(d => d.id !== deckId))
} catch { }
}
if (loading && decks.length === 0 && cards.length === 0) return <div className="loading"><div className="spinner" /></div>
return (
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ marginBottom: 0 }}>Flashcards</h2>
<div style={{ display: 'flex', gap: 4 }}>
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>Decks</button>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Cards</button>
</div>
</div>
</div>
{/* Study card modal */}
{studyCard && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && (setStudyCard(null), setFlipped(false))}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 32, maxWidth: 500, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', cursor: 'pointer', minHeight: 200, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
onClick={() => setFlipped(v => !v)}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 12, textTransform: 'uppercase', fontWeight: 700 }}>
{flipped ? 'Answer' : 'Question'} tap to flip
</div>
<p style={{ fontSize: '1.1rem', lineHeight: 1.7 }}>
{flipped ? studyCard.back : studyCard.front}
</p>
<div style={{ marginTop: 20, display: 'flex', gap: 8, justifyContent: 'center' }} onClick={e => e.stopPropagation()}>
<button className="btn btn-secondary btn-sm" onClick={() => { setStudyCard(null); setFlipped(false) }}>Close</button>
</div>
</div>
</div>
)}
{tab === 'decks' && (
<>
{decks.length === 0 && !loading && (
<div className="card"><div className="empty-state">No flashcard decks yet. Create one from a document.</div></div>
)}
<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>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>
{deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()}
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
{isModerator && (
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'browse' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<input type="text" placeholder="Search cards..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
style={{ width: '100%', padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<select value={filterDeckId} onChange={e => setFilterDeckId(e.target.value)}
style={{ padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">All decks</option>
{decks.map(d => <option key={d.id} value={d.id}>{d.title} ({d.card_count})</option>)}
</select>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards total</p>
{cards.map(card => (
<div key={card.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--border)',
borderRadius: 'var(--card-radius)', padding: '14px 16px', marginBottom: 8,
cursor: 'pointer',
}} onClick={() => { setStudyCard(card); setFlipped(false) }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>{card.deck_title}</span>
</div>
<p style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}>{card.front}</p>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}</p>
</div>
))}
{!loading && cards.length === 0 && (
<div className="card"><div className="empty-state">No cards found.</div></div>
)}
{cards.length < total && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button className="btn btn-secondary" onClick={() => loadCards(searchQuery, cards.length, filterDeckId)} disabled={loading}>
{loading ? 'Loading...' : `Load more (${total - cards.length} remaining)`}
</button>
</div>
)}
</>
)}
</div>
)
}