From 3e37bf2128293aa463cb3b788be6118264013459 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 4 Apr 2026 22:59:21 +0200 Subject: [PATCH] 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) --- backend/app/main.py | 33 ++- backend/app/models/flashcard.py | 26 ++ backend/app/routers/admin.py | 2 +- backend/app/routers/flashcards.py | 316 ++++++++++++++++++++++ backend/app/schemas/flashcard.py | 29 ++ backend/app/services/extraction_modes.py | 66 +++++ backend/app/tasks/quiz_tasks.py | 110 ++++++++ frontend/src/App.jsx | 4 + frontend/src/components/Navbar.jsx | 1 + frontend/src/pages/AdminPage.jsx | 2 +- frontend/src/pages/DocumentDetailPage.jsx | 32 ++- frontend/src/pages/FlashcardStudyPage.jsx | 188 +++++++++++++ frontend/src/pages/FlashcardsPage.jsx | 162 +++++++++++ 13 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 backend/app/models/flashcard.py create mode 100644 backend/app/routers/flashcards.py create mode 100644 backend/app/schemas/flashcard.py create mode 100644 frontend/src/pages/FlashcardStudyPage.jsx create mode 100644 frontend/src/pages/FlashcardsPage.jsx diff --git a/backend/app/main.py b/backend/app/main.py index 73ad032..aa6413f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from app.config import settings 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.scheduler import start_scheduler, stop_scheduler @@ -131,6 +131,7 @@ def setup_pgvector(): from sqlalchemy import text # 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 flashcard # noqa # Kill stale idle-in-transaction connections from previous killed startups. # 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) ) """)) + # 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() @@ -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(contact.router, prefix="/api/contact", tags=["contact"]) app.include_router(tags.router, prefix="/api/tags", tags=["tags"]) +app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"]) @app.get("/api/health") diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py new file mode 100644 index 0000000..3e8416a --- /dev/null +++ b/backend/app/models/flashcard.py @@ -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") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 0e1d0b0..07da10d 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -187,7 +187,7 @@ def create_model( db: Session = Depends(get_db), 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") if data.is_default: diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py new file mode 100644 index 0000000..37a74f1 --- /dev/null +++ b/backend/app/routers/flashcards.py @@ -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() diff --git a/backend/app/schemas/flashcard.py b/backend/app/schemas/flashcard.py new file mode 100644 index 0000000..a612279 --- /dev/null +++ b/backend/app/schemas/flashcard.py @@ -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] = [] diff --git a/backend/app/services/extraction_modes.py b/backend/app/services/extraction_modes.py index 4ab1350..576fb9c 100644 --- a/backend/app/services/extraction_modes.py +++ b/backend/app/services/extraction_modes.py @@ -467,3 +467,69 @@ def ai_decide_strategy( 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": "", + "back": "", + "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 [] diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index c5413d9..7f4272a 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -497,3 +497,113 @@ def regenerate_embeddings(self, job_id: str, user_id: int): raise finally: 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() \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 66184b3..979d252 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,6 +22,8 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage' import NotFoundPage from './pages/NotFoundPage' import LandingPage from './pages/LandingPage' +import FlashcardsPage from './pages/FlashcardsPage' +import FlashcardStudyPage from './pages/FlashcardStudyPage' // Layout wrapper for authenticated app pages (Navbar + container + footer) function AppLayout() { @@ -72,6 +74,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index f4fead5..58f403f 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -92,6 +92,7 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/', label: 'Dashboard' }, { to: '/quizzes', label: 'Quizzes' }, { to: '/question-bank', label: 'Question Bank' }, + { to: '/flashcards', label: 'Flashcards' }, ...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []), { to: '/settings', label: '⚙ Settings' }, ] : [] diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index f70ab74..ef6437c 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -5,7 +5,7 @@ import api from '../api/client' import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' -const TASKS = ['extraction', 'tts', 'teach', 'keyword'] +const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard'] export default function AdminPage() { const { user } = useAuth() diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index d896990..e8ebca4 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -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) => { setGenerating(sectionId) setError('') @@ -463,7 +486,14 @@ export default function DocumentDetailPage() { > {generating === section.id ? ( <> Extracting... - ) : 'Extract & Create Quiz'} + ) : 'Extract Quiz'} + + deleteSection(section.id)} diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx new file mode 100644 index 0000000..de43834 --- /dev/null +++ b/frontend/src/pages/FlashcardStudyPage.jsx @@ -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
+ if (!deck) return null + + const allDone = known.size === cards.length + + return ( +
+ {/* Header */} +
+
+
+

{deck.title}

+

+ {cards.length} cards · {known.size} known · {review.size} to review +

+
+
+ + {review.size > 0 && ( + + )} + + + +
+
+ {/* Progress bar */} +
+
+
+
+
{progress}% mastered
+
+
+ + {/* Completion screen */} + {allDone && ( +
+
🎉
+

All cards mastered!

+

You marked all {cards.length} cards as known.

+
+ + +
+
+ )} + + {/* Card */} + {!allDone && total > 0 && currentCard && ( + <> +
setFlipped(v => !v)} + > +
+ {flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total} +
+

+ {flipped ? currentCard.back : currentCard.front} +

+ {!flipped && ( +
Tap to reveal answer
+ )} +
+ + {/* Controls */} +
+ + {flipped && ( + <> + + + + )} + +
+
+ Keyboard: Space/Enter=flip, ←→=navigate, 1=got it, 2=review +
+ + )} + + {!allDone && total === 0 && ( +
No cards to show. {mode === 'review' ? 'No cards marked for review.' : ''}
+ )} +
+ ) +} diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx new file mode 100644 index 0000000..5d6a30c --- /dev/null +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -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
+ + return ( +
+
+
+

Flashcards

+
+ + +
+
+
+ + {/* Study card modal */} + {studyCard && ( +
e.target === e.currentTarget && (setStudyCard(null), setFlipped(false))}> +
setFlipped(v => !v)}> +
+ {flipped ? 'Answer' : 'Question'} — tap to flip +
+

+ {flipped ? studyCard.back : studyCard.front} +

+
e.stopPropagation()}> + +
+
+
+ )} + + {tab === 'decks' && ( + <> + {decks.length === 0 && !loading && ( +
No flashcard decks yet. Create one from a document.
+ )} +
+ {decks.map(deck => ( +
+

{deck.title}

+

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

+
+ Study + {isModerator && ( + + )} +
+
+ ))} +
+ + )} + + {tab === 'browse' && ( + <> +
+
+ 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)' }} /> +
+ +
+ +

{total} cards total

+ + {cards.map(card => ( +
{ setStudyCard(card); setFlipped(false) }}> +
+ {card.deck_title} +
+

{card.front}

+

{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}

+
+ ))} + + {!loading && cards.length === 0 && ( +
No cards found.
+ )} + + {cards.length < total && ( +
+ +
+ )} + + )} +
+ ) +}