diff --git a/backend/alembic/versions/983ad2983d5c_add_selected_resource_ids_to_chat_.py b/backend/alembic/versions/983ad2983d5c_add_selected_resource_ids_to_chat_.py new file mode 100644 index 0000000..e078761 --- /dev/null +++ b/backend/alembic/versions/983ad2983d5c_add_selected_resource_ids_to_chat_.py @@ -0,0 +1,32 @@ +"""add selected_resource_ids to chat_sessions + +Revision ID: 983ad2983d5c +Revises: d3dcf6b6fe3f +Create Date: 2026-05-18 20:55:14.773697 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '983ad2983d5c' +down_revision: Union[str, Sequence[str], None] = 'd3dcf6b6fe3f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_sessions', sa.Column('selected_resource_ids', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('chat_sessions', 'selected_resource_ids') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/ab47c0990b86_add_question_pdf_url_to_paper_outputs.py b/backend/alembic/versions/ab47c0990b86_add_question_pdf_url_to_paper_outputs.py new file mode 100644 index 0000000..41bb74d --- /dev/null +++ b/backend/alembic/versions/ab47c0990b86_add_question_pdf_url_to_paper_outputs.py @@ -0,0 +1,32 @@ +"""add question_pdf_url to paper_outputs + +Revision ID: ab47c0990b86 +Revises: 983ad2983d5c +Create Date: 2026-05-18 21:35:55.165368 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ab47c0990b86' +down_revision: Union[str, Sequence[str], None] = '983ad2983d5c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('paper_outputs', sa.Column('question_pdf_url', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('paper_outputs', 'question_pdf_url') + # ### end Alembic commands ### diff --git a/backend/app/config.py b/backend/app/config.py index e100e7b..dd9d26a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,8 @@ class Settings(BaseSettings): FRONTEND_URL: str = "http://localhost:5173" QUESTIONS_LIMIT: int = 30 + PAPERS_LIMIT: int = 3 + RESOURCES_LIMIT: int = 3 class Config: env_file = ".env" diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index c0447ba..e8a5c4e 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -9,6 +9,7 @@ def __init__(self): self.base_url = "https://openrouter.ai/api/v1/chat/completions" async def stream_chat(self, messages: list, model: str = "openrouter/owl-alpha") -> AsyncGenerator[str, None]: + # async def stream_chat(self, messages: list, model: str = "nvidia/nemotron-3-super-120b-a12b:free") -> AsyncGenerator[str, None]: headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", diff --git a/backend/app/llm/prompts.py b/backend/app/llm/prompts.py index b0526e4..9f4e2b0 100644 --- a/backend/app/llm/prompts.py +++ b/backend/app/llm/prompts.py @@ -20,3 +20,42 @@ Question: {question} """ + +DETECT_FORMAT_PROMPT = """ +Analyse this past year paper and extract the question format. +Return ONLY a JSON object with this exact structure, no explanation: + +{ + "mcq": , + "short": , + "long": , + "mcq_marks": , + "short_marks": , + "long_marks": , + "total_marks": , + "duration_minutes": +} +""" + +GENERATE_PAPER_PROMPT = """ +You are generating a sample exam paper for a student. + +Format config: {format_config} +Subject context from student's material: +{context_chunks} + +Generate exactly the number of questions specified in the format config. +Return ONLY a JSON array of question objects. Each object must have: + - type (mcq | short | long) + - marks (integer) + - topic (string) + - question_text (string) + - For MCQ: also include options (array of 4 strings) and answer (correct option text) + - For short/long: also include answer (model answer string) and explanation (string) + +Rules: +- Distribute questions across different topics evenly. +- No repeated questions. +- Difficulty should match a real exam for this level. +- JSON array only. No preamble, no explanation, no markdown fences. +""" diff --git a/backend/app/main.py b/backend/app/main.py index b018028..6be2fff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import auth, resources, solver +from .routers import auth, resources, solver, papers from .config import settings app = FastAPI(title="PYQ Solver API") @@ -18,6 +18,7 @@ app.include_router(auth.router, prefix="/api") app.include_router(resources.router, prefix="/api") app.include_router(solver.router, prefix="/api") +app.include_router(papers.router, prefix="/api") @app.get("/") async def root(): diff --git a/backend/app/models/chat_session.py b/backend/app/models/chat_session.py index b3fd09f..d78b41e 100644 --- a/backend/app/models/chat_session.py +++ b/backend/app/models/chat_session.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime, UUID, ForeignKey +from sqlalchemy import Column, String, DateTime, UUID, ForeignKey, JSON from sqlalchemy.orm import relationship from datetime import datetime import uuid @@ -10,6 +10,7 @@ class ChatSession(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) title = Column(String, default="New Chat") + selected_resource_ids = Column(JSON, default=list) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/models/paper_output.py b/backend/app/models/paper_output.py index 5f3372a..af4adc0 100644 --- a/backend/app/models/paper_output.py +++ b/backend/app/models/paper_output.py @@ -12,7 +12,8 @@ class PaperOutput(Base): questions = Column(JSON, nullable=False) # Array of question objects include_answers = Column(Boolean, default=True) include_explanations = Column(Boolean, default=True) - pdf_url = Column(String, nullable=True) + pdf_url = Column(String, nullable=True) # Full version (with answers/explanations as per toggles) + question_pdf_url = Column(String, nullable=True) # Clean question paper only created_at = Column(DateTime, default=datetime.utcnow) paper = relationship("Paper", back_populates="output") diff --git a/backend/app/routers/papers.py b/backend/app/routers/papers.py new file mode 100644 index 0000000..2c88262 --- /dev/null +++ b/backend/app/routers/papers.py @@ -0,0 +1,381 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from typing import List +import uuid +import json +import logging + +from ..database import get_db +from ..models.user import User +from ..models.resource import Resource +from ..models.paper import Paper, paper_resources +from ..models.paper_output import PaperOutput +from ..schemas.paper import ( + PaperCreate, PaperOut, PaperUpdate, + PaperOutputOut, PaperOutputToggle, + FormatDetectionRequest +) +from .auth import get_current_user +from ..llm.client import open_router_client +from ..llm.prompts import DETECT_FORMAT_PROMPT +from ..config import settings +from arq import create_pool +from arq.connections import RedisSettings +from ..services.pdf import generate_paper_pdf +from ..services.storage import storage_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/papers", tags=["papers"]) + +@router.post("/detect-format") +async def detect_format( + data: FormatDetectionRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Fetch the resource + result = await db.execute( + select(Resource).where(Resource.id == data.resource_id, Resource.user_id == current_user.id) + ) + resource = result.scalar_one_or_none() + + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + + if not resource.extracted_text: + raise HTTPException(status_code=400, detail="Resource has no extracted text. Please wait for processing.") + + # Call LLM for format detection + messages = [ + {"role": "system", "content": "You are a document analyzer."}, + {"role": "user", "content": f"{DETECT_FORMAT_PROMPT}\n\nPaper Content:\n{resource.extracted_text[:10000]}"} + ] + + try: + # Collect stream into full response + full_response = "" + async for chunk in open_router_client.stream_chat(messages): + full_response += chunk + + # Parse JSON from response + # LLM might return markdown code blocks, strip them if present + clean_json = full_response.strip() + if clean_json.startswith("```json"): + clean_json = clean_json[7:] + if clean_json.endswith("```"): + clean_json = clean_json[:-3] + + format_config = json.loads(clean_json) + return format_config + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to detect format: {str(e)}") + +@router.post("", response_model=PaperOut) +async def create_paper( + data: PaperCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + if not data.resources: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one resource must be selected." + ) + + # 1. Quota Check + if current_user.plan == "free": + from datetime import datetime, date + + now = datetime.utcnow() + start_of_month = datetime(now.year, now.month, 1) + + count_result = await db.execute( + select(func.count(Paper.id)) + .where(Paper.user_id == current_user.id) + .where(Paper.created_at >= start_of_month) + ) + paper_count = count_result.scalar_one() + if paper_count >= settings.PAPERS_LIMIT: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Free plan limit reached ({settings.PAPERS_LIMIT} papers per month). Please upgrade to generate more." + ) + + # 2. Create Paper record + new_paper = Paper( + user_id=current_user.id, + title=data.title, + format_config=data.format_config or {}, + delivery_mode=data.delivery_mode, + status="pending" + ) + db.add(new_paper) + await db.flush() + + # 3. Link Resources + for res_link in data.resources: + # Verify resource exists and belongs to user + res_result = await db.execute( + select(Resource).where(Resource.id == res_link.resource_id, Resource.user_id == current_user.id) + ) + if not res_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Resource {res_link.resource_id} not found or unauthorized." + ) + + # Insert into association table + await db.execute( + paper_resources.insert().values( + paper_id=new_paper.id, + resource_id=res_link.resource_id, + resource_role=res_link.role + ) + ) + + await db.commit() + await db.refresh(new_paper) + + from ..models.job import Job + new_job = Job( + user_id=current_user.id, + job_type="generate_paper", + status="queued", + ref_id=new_paper.id + ) + db.add(new_job) + await db.flush() + + # 4. Enqueue background task + redis = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) + try: + job = await redis.enqueue_job("generate_paper_task", str(new_paper.id), str(new_job.id)) + if job is None: + raise RuntimeError("Failed to enqueue generate_paper_task") + await db.commit() + except Exception as e: + logger.error(f"Redis enqueue error: {e}") + new_paper.status = "failed" + await db.commit() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Paper generation queued failed, please retry." + ) + finally: + await redis.close() + + return new_paper + +@router.get("", response_model=List[PaperOut]) +async def list_papers( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Fetch papers with resource count and question count + # We use a subquery for resource count to avoid complex joins issues in async sqlalchemy + from sqlalchemy.orm import selectinload + + result = await db.execute( + select(Paper) + .where(Paper.user_id == current_user.id) + .options(selectinload(Paper.resources), selectinload(Paper.output)) + .order_by(Paper.created_at.desc()) + ) + papers = result.scalars().all() + + # Map counts manually to the objects (SQLAlchemy will handle serialization to PaperOut) + for p in papers: + p.resource_count = len(p.resources) + p.question_count = len(p.output.questions) if p.output else 0 + + return papers + +@router.get("/{paper_id}", response_model=PaperOut) +async def get_paper( + paper_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + result = await db.execute( + select(Paper).where(Paper.id == paper_id, Paper.user_id == current_user.id) + ) + paper = result.scalar_one_or_none() + if not paper: + raise HTTPException(status_code=404, detail="Paper not found") + return paper + +@router.patch("/{paper_id}", response_model=PaperOut) +async def update_paper( + paper_id: uuid.UUID, + data: PaperUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + result = await db.execute( + select(Paper).where(Paper.id == paper_id, Paper.user_id == current_user.id) + ) + paper = result.scalar_one_or_none() + if not paper: + raise HTTPException(status_code=404, detail="Paper not found") + + if data.title: + paper.title = data.title + + await db.commit() + await db.refresh(paper) + return paper + +@router.delete("/{paper_id}") +async def delete_paper( + paper_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + result = await db.execute( + select(Paper).where(Paper.id == paper_id, Paper.user_id == current_user.id) + ) + paper = result.scalar_one_or_none() + + if not paper: + raise HTTPException(status_code=404, detail="Paper not found") + + # Check if there are outputs with PDFs to clean up storage + output_result = await db.execute( + select(PaperOutput).where(PaperOutput.paper_id == paper_id) + ) + output = output_result.scalar_one_or_none() + + if output: + if output.pdf_url: + object_name = output.pdf_url.replace(f"{settings.SPACES_PUBLIC_URL}/", "") + storage_service.delete_file(object_name) + if output.question_pdf_url: + object_name = output.question_pdf_url.replace(f"{settings.SPACES_PUBLIC_URL}/", "") + storage_service.delete_file(object_name) + + # Deleting the paper will cascade and delete PaperOutput and paper_resources automatically + await db.delete(paper) + await db.commit() + + return {"message": "Paper deleted successfully"} + +@router.get("/{paper_id}/output", response_model=PaperOutputOut) +async def get_paper_output( + paper_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Verify paper ownership + result = await db.execute( + select(Paper).where(Paper.id == paper_id, Paper.user_id == current_user.id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Paper not found") + + output_result = await db.execute( + select(PaperOutput).where(PaperOutput.paper_id == paper_id) + ) + output = output_result.scalar_one_or_none() + if not output: + raise HTTPException(status_code=404, detail="Paper output not yet generated") + + return output + +@router.patch("/{paper_id}/output", response_model=PaperOutputOut) +async def toggle_output_settings( + paper_id: uuid.UUID, + data: PaperOutputToggle, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # Verify paper ownership + result = await db.execute( + select(Paper).where(Paper.id == paper_id, Paper.user_id == current_user.id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Paper not found") + + output_result = await db.execute( + select(PaperOutput).where(PaperOutput.paper_id == paper_id) + ) + output = output_result.scalar_one_or_none() + if not output: + raise HTTPException(status_code=404, detail="Paper output not found") + + if data.include_answers is not None: + output.include_answers = data.include_answers + if data.include_explanations is not None: + output.include_explanations = data.include_explanations + + await db.commit() + await db.refresh(output) + return output + +@router.get("/{paper_id}/pdf") +async def get_paper_pdf_url( + paper_id: uuid.UUID, + mode: str = "full", # full | questions_only + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + # 1. Fetch paper and output + result = await db.execute( + select(Paper).where(Paper.id == paper_id, Paper.user_id == current_user.id) + ) + paper = result.scalar_one_or_none() + if not paper: + raise HTTPException(status_code=404, detail="Paper not found") + + output_result = await db.execute( + select(PaperOutput).where(PaperOutput.paper_id == paper_id) + ) + output = output_result.scalar_one_or_none() + if not output: + raise HTTPException(status_code=404, detail="Paper output not yet generated") + + # 2. Check if specific PDF already exists + if mode == "questions_only" and output.question_pdf_url: + return {"url": output.question_pdf_url} + elif mode == "full" and output.pdf_url: + return {"url": output.pdf_url} + + # 3. Determine PDF settings based on mode + include_answers = False + include_explanations = False + + if mode == "full": + include_answers = output.include_answers + include_explanations = output.include_explanations + + # 4. Generate PDF + try: + pdf_file = await generate_paper_pdf( + title=paper.title + (" (Questions Only)" if mode == "questions_only" else ""), + questions=output.questions, + format_config=paper.format_config, + include_answers=include_answers, + include_explanations=include_explanations + ) + except Exception as e: + logger.error(f"PDF Generation Error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to generate PDF: {str(e)}") + + # 5. Upload to Spaces + suffix = "questions" if mode == "questions_only" else "full" + object_name = f"papers/{paper_id}_{suffix}_{uuid.uuid4().hex[:8]}.pdf" + pdf_url = storage_service.upload_file(pdf_file.getvalue(), object_name) + + if not pdf_url: + raise HTTPException(status_code=500, detail="Failed to upload PDF to storage") + + # 6. Save URL to DB + if mode == "questions_only": + output.question_pdf_url = pdf_url + else: + output.pdf_url = pdf_url + + await db.commit() + + return {"url": pdf_url} diff --git a/backend/app/routers/resources.py b/backend/app/routers/resources.py index 2fd66cb..9d7a050 100644 --- a/backend/app/routers/resources.py +++ b/backend/app/routers/resources.py @@ -29,6 +29,17 @@ async def upload_resource( detail="Only PDF and Text files are supported" ) + # Quota check + if current_user.plan == "free": + from sqlalchemy import func + count_result = await db.execute(select(func.count(Resource.id)).where(Resource.user_id == current_user.id)) + resource_count = count_result.scalar_one() + if resource_count >= settings.RESOURCES_LIMIT: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Free plan limit reached ({settings.RESOURCES_LIMIT} resources). Please upgrade to upload more." + ) + # Chunked read with size enforcement (CodeRabbit fix) MAX_BYTES = settings.MAX_FILE_SIZE_MB * 1024 * 1024 CHUNK_SIZE = 1024 * 1024 # 1MB chunks @@ -88,10 +99,21 @@ async def upload_resource( db.add(new_resource) await db.flush() # Flush to get the ID but don't commit + from ..models.job import Job + new_job = Job( + user_id=current_user.id, + job_type="ingest", + status="queued", + ref_id=new_resource.id + ) + db.add(new_job) + await db.flush() + # Enqueue background extraction task before committing DB try: redis = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) - await redis.enqueue_job('extraction_task', str(new_resource.id)) + # Pass job_id as second argument + await redis.enqueue_job('extraction_task', str(new_resource.id), str(new_job.id)) # Only commit if enqueue was successful await db.commit() @@ -103,6 +125,9 @@ async def upload_resource( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to queue background task: {str(e)}" ) + finally: + if 'redis' in locals(): + await redis.close() return new_resource @@ -185,10 +210,20 @@ async def retry_extraction( # Update status to processing (don't commit yet) resource.status = "processing" + from ..models.job import Job + new_job = Job( + user_id=current_user.id, + job_type="ingest", + status="queued", + ref_id=resource.id + ) + db.add(new_job) + await db.flush() + # Re-enqueue background extraction task before committing DB try: redis = await create_pool(RedisSettings.from_dsn(settings.REDIS_URL)) - await redis.enqueue_job('extraction_task', str(resource.id)) + await redis.enqueue_job('extraction_task', str(resource.id), str(new_job.id)) # Only commit if enqueue was successful await db.commit() @@ -199,6 +234,9 @@ async def retry_extraction( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to queue background task: {str(e)}" ) + finally: + if 'redis' in locals(): + await redis.close() return resource diff --git a/backend/app/routers/solver.py b/backend/app/routers/solver.py index 5540569..92b82cd 100644 --- a/backend/app/routers/solver.py +++ b/backend/app/routers/solver.py @@ -116,6 +116,9 @@ async def update_session( if data.title: session.title = data.title + if data.selected_resource_ids is not None: + # Convert UUIDs to strings for JSON storage + session.selected_resource_ids = [str(rid) for rid in data.selected_resource_ids] await db.commit() await db.refresh(session) @@ -165,12 +168,22 @@ async def ask_question( sess_result = await db.execute( select(ChatSession).where(ChatSession.id == data.session_id, ChatSession.user_id == current_user.id) ) - if not sess_result.scalar_one_or_none(): + session = sess_result.scalar_one_or_none() + if not session: raise HTTPException(status_code=404, detail="Chat session not found") session_id = data.session_id + + # Update persistent resource selection if changed + new_resource_ids = [str(rid) for rid in data.resource_ids] + if session.selected_resource_ids != new_resource_ids: + session.selected_resource_ids = new_resource_ids else: # Auto-create session if none provided - new_sess = ChatSession(user_id=current_user.id, title=data.content[:30] + "...") + new_sess = ChatSession( + user_id=current_user.id, + title=data.content[:30] + "...", + selected_resource_ids=[str(rid) for rid in data.resource_ids] + ) db.add(new_sess) await db.flush() session_id = new_sess.id diff --git a/backend/app/schemas/chat_session.py b/backend/app/schemas/chat_session.py index 875b520..3b9d60a 100644 --- a/backend/app/schemas/chat_session.py +++ b/backend/app/schemas/chat_session.py @@ -11,10 +11,12 @@ class ChatSessionCreate(ChatSessionBase): class ChatSessionUpdate(BaseModel): title: Optional[str] = None + selected_resource_ids: Optional[List[UUID]] = None class ChatSessionOut(ChatSessionBase): id: UUID user_id: UUID + selected_resource_ids: Optional[List[UUID]] = [] created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/paper.py b/backend/app/schemas/paper.py new file mode 100644 index 0000000..62ff2cf --- /dev/null +++ b/backend/app/schemas/paper.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from uuid import UUID +from datetime import datetime + +class PaperResourceLink(BaseModel): + resource_id: UUID + role: str # syllabus | past_paper | notes + +class PaperCreate(BaseModel): + title: str + resources: List[PaperResourceLink] + format_config: Optional[Dict[str, Any]] = None + delivery_mode: str = "background" + +class PaperUpdate(BaseModel): + title: Optional[str] = None + status: Optional[str] = None + +class PaperOutputToggle(BaseModel): + include_answers: Optional[bool] = None + include_explanations: Optional[bool] = None + +class PaperOut(BaseModel): + id: UUID + user_id: UUID + title: str + format_config: Dict[str, Any] + status: str + delivery_mode: str + resource_count: Optional[int] = 0 + question_count: Optional[int] = 0 + created_at: datetime + + class Config: + from_attributes = True + +class PaperQuestion(BaseModel): + type: str # mcq | short | long + marks: int + topic: str + question_text: str + options: Optional[List[str]] = None + answer: str + explanation: str + +class PaperOutputOut(BaseModel): + id: UUID + paper_id: UUID + questions: List[Dict[str, Any]] + include_answers: bool + include_explanations: bool + pdf_url: Optional[str] = None + question_pdf_url: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + +class FormatDetectionRequest(BaseModel): + resource_id: UUID diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py new file mode 100644 index 0000000..fab1b11 --- /dev/null +++ b/backend/app/services/pdf.py @@ -0,0 +1,56 @@ +import io +from jinja2 import Environment, FileSystemLoader +from weasyprint import HTML +from typing import List, Dict, Any +import os + +# Set up Jinja2 environment +template_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates") +env = Environment(loader=FileSystemLoader(template_dir)) + +def map_option_letter(index: str) -> str: + """Helper to map 0, 1, 2, 3 to A, B, C, D""" + return chr(65 + int(index)) + +env.filters['map_option_letter'] = map_option_letter + +async def generate_paper_pdf( + title: str, + questions: List[Dict[str, Any]], + format_config: Dict[str, Any], + include_answers: bool = False, + include_explanations: bool = False +) -> io.BytesIO: + """ + Generates a PDF from a list of questions and returns a BytesIO object. + """ + # Group questions by type for sections + mcqs = [q for q in questions if q.get('type') == 'mcq'] + shorts = [q for q in questions if q.get('type') == 'short'] + longs = [q for q in questions if q.get('type') == 'long'] + + sections = [] + if mcqs: + sections.append({"name": "Section A: Multiple Choice Questions", "questions": mcqs}) + if shorts: + sections.append({"name": "Section B: Short Answer Questions", "questions": shorts}) + if longs: + sections.append({"name": "Section C: Long Answer Questions", "questions": longs}) + + # Render HTML + template = env.get_template("paper.html") + html_content = template.render( + title=title, + total_marks=format_config.get('total_marks', 'N/A'), + duration=format_config.get('duration_minutes', 'N/A'), + sections=sections, + include_answers=include_answers, + include_explanations=include_explanations + ) + + # Convert to PDF + pdf_output = io.BytesIO() + HTML(string=html_content).write_pdf(pdf_output) + pdf_output.seek(0) + + return pdf_output diff --git a/backend/app/templates/paper.html b/backend/app/templates/paper.html new file mode 100644 index 0000000..c6ea3a3 --- /dev/null +++ b/backend/app/templates/paper.html @@ -0,0 +1,161 @@ + + + + + {{ title }} + + + +
+

{{ title }}

+

Model Sample Paper

+
+ +
+
Total Marks: {{ total_marks }}
+
Duration: {{ duration }} mins
+
Generated by: AI Tutor
+
+ + {% for section in sections %} +
{{ section.name }}
+ + {% for q in section.questions %} +
+
+ Q{{ loop.index }}. {{ q.topic }} + [{{ q.marks }} Marks] +
+
+ {{ q.question_text }} +
+ + {% if q.type == 'mcq' %} +
+ {% for opt in q.options %} +
{{ loop.index0 | string | map_option_letter }}) {{ opt }}
+ {% endfor %} +
+ {% endif %} + + {% if include_answers %} +
+ Model Answer:
+ {{ q.answer }} +
+ {% endif %} + + {% if include_explanations and q.explanation %} +
+ Explanation:
+ {{ q.explanation }} +
+ {% endif %} +
+ {% endfor %} + {% endfor %} + + + + diff --git a/backend/app/workers/arq_worker.py b/backend/app/workers/arq_worker.py index 392e0fe..755e9c5 100644 --- a/backend/app/workers/arq_worker.py +++ b/backend/app/workers/arq_worker.py @@ -1,7 +1,8 @@ import asyncio from arq.connections import RedisSettings +from arq.cron import cron from app.config import settings -from .tasks import extraction_task +from .tasks import extraction_task, generate_paper_task, reset_monthly_quotas async def ping(ctx): return "pong" @@ -13,7 +14,10 @@ async def shutdown(ctx): pass class WorkerSettings: - functions = [ping, extraction_task] + functions = [ping, extraction_task, generate_paper_task] + cron_jobs = [ + cron(reset_monthly_quotas, day=1, hour=0, minute=0) + ] on_startup = startup on_shutdown = shutdown redis_settings = RedisSettings.from_dsn(settings.REDIS_URL) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index e14ea66..c054cf6 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -16,17 +16,29 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -async def extraction_task(ctx, resource_id: str): +async def extraction_task(ctx, resource_id: str, job_id: str = None): print(f"\nšŸš€ [TASK START] Resource ID: {resource_id}") async with SessionLocal() as db: try: + if job_id: + from ..models.job import Job + job_res = await db.execute(select(Job).where(Job.id == job_id)) + job = job_res.scalar_one_or_none() + if job: + job.status = "running" + await db.commit() + # 1. Fetch Resource result = await db.execute(select(Resource).where(Resource.id == resource_id)) resource = result.scalar_one_or_none() if not resource: print(f"āŒ [DB ERROR] Resource {resource_id} not found.") + if job_id: + job.status = "failed" + job.error = "Resource not found" + await db.commit() return print(f"šŸ“„ [PROCESSING] Filename: {resource.filename} | Type: {resource.type}") @@ -34,6 +46,9 @@ async def extraction_task(ctx, resource_id: str): # 2. Check for initial cancellation if resource.status != "processing": print(f"āš ļø [ABORTED] Task aborted before starting (Status: {resource.status})") + if job_id: + job.status = "done" + await db.commit() return # 3. Handle PDF Files @@ -67,6 +82,13 @@ async def extraction_task(ctx, resource_id: str): db_res = check_res.scalar_one_or_none() if not db_res or db_res.status != "processing": print(f"šŸ›‘ [STOPPED] Cancellation detected at Page {i+1}. Aborting loop.") + if job_id: + async with SessionLocal() as job_db: + j_res = await job_db.execute(select(Job).where(Job.id == job_id)) + j = j_res.scalar_one_or_none() + if j: + j.status = "done" + await job_db.commit() return # Update Progress % @@ -128,10 +150,23 @@ async def extraction_task(ctx, resource_id: str): db_res_final = f_res.scalar_one_or_none() if not db_res_final or db_res_final.status != "processing": print("šŸ›‘ [ABORTED] Final commit skipped. User stopped task during last page.") + if job_id: + async with SessionLocal() as job_db: + j_res = await job_db.execute(select(Job).where(Job.id == job_id)) + j = j_res.scalar_one_or_none() + if j: + j.status = "done" + await job_db.commit() return resource.extracted_text = "\n\n".join(full_text) resource.status = "ready" + + if job_id: + job.status = "done" + from datetime import datetime + job.completed_at = datetime.utcnow() + print(f"šŸ’¾ [STEP 3/3] Saving extracted text to DB...") await db.commit() print(f"šŸ [TASK COMPLETE] Resource {resource_id} is now READY.\n") @@ -142,11 +177,23 @@ async def extraction_task(ctx, resource_id: str): response = await client.get(resource.file_url) resource.extracted_text = response.text resource.status = "ready" + + if job_id: + job.status = "done" + from datetime import datetime + job.completed_at = datetime.utcnow() + await db.commit() print("šŸ [TASK COMPLETE] Text file is READY.\n") else: resource.status = "failed" + if job_id: + job.status = "failed" + job.error = f"Unsupported file type: {resource.filename}" + from datetime import datetime + job.completed_at = datetime.utcnow() + print(f"āŒ [ERROR] Unsupported file type: {resource.filename}") await db.commit() @@ -162,7 +209,204 @@ async def extraction_task(ctx, resource_id: str): res_to_fail = update_result.scalar_one_or_none() if res_to_fail and res_to_fail.status == "processing": res_to_fail.status = "failed" - await err_db.commit() - print(f"šŸ“‰ [DB] Resource {resource_id} marked as FAILED.") + + if job_id: + from ..models.job import Job + j_res = await err_db.execute(select(Job).where(Job.id == job_id)) + j = j_res.scalar_one_or_none() + if j: + j.status = "failed" + j.error = str(e) + from datetime import datetime + j.completed_at = datetime.utcnow() + + await err_db.commit() + print(f"šŸ“‰ [DB] Resource {resource_id} marked as FAILED.") except Exception as final_err: print(f"šŸ’€ [FATAL] Could not even mark as failed: {final_err}") + +async def generate_paper_task(ctx, paper_id: str, job_id: str = None): + import json + from sqlalchemy.orm import selectinload + from ..models.paper import Paper + from ..models.paper_output import PaperOutput + from ..llm.client import open_router_client + from ..llm.prompts import GENERATE_PAPER_PROMPT + + print(f"\nšŸš€ [TASK START] Paper Generation: {paper_id}") + + async with SessionLocal() as db: + try: + if job_id: + from ..models.job import Job + job_res = await db.execute(select(Job).where(Job.id == job_id)) + job = job_res.scalar_one_or_none() + if job: + job.status = "running" + await db.commit() + + # 1. Fetch Paper with Resources + print(f"šŸ“” [DEBUG] Fetching paper metadata and linked resources...") + result = await db.execute( + select(Paper) + .where(Paper.id == paper_id) + .options(selectinload(Paper.resources)) + ) + paper = result.scalar_one_or_none() + + if not paper: + print(f"āŒ [DB ERROR] Paper {paper_id} not found") + if job_id: + job.status = "failed" + job.error = "Paper not found" + await db.commit() + return + + print(f"šŸ“ [DEBUG] Paper Title: {paper.title}") + print(f"āš™ļø [DEBUG] Format Config: {paper.format_config}") + + paper.status = "generating" + await db.commit() + print(f"šŸ”„ [STATUS] Set to 'generating'") + + # 2. Combine Context + print(f"šŸ“š [DEBUG] Aggregating context from {len(paper.resources)} resources...") + combined_context = "" + for res in paper.resources: + if res.extracted_text: + content_len = len(res.extracted_text) + print(f"šŸ“Ž [DEBUG] Adding resource: {res.filename} ({content_len} chars)") + combined_context += f"--- Source ({res.type}): {res.filename} ---\n{res.extracted_text}\n\n" + else: + print(f"āš ļø [DEBUG] Resource {res.filename} has no extracted text. Skipping.") + + if not combined_context: + raise Exception("No context found in selected resources.") + + context_total_len = len(combined_context) + print(f"šŸ“Š [DEBUG] Total Context Length: {context_total_len} characters") + + # 3. Call LLM + print(f"šŸ¤– [LLM] Preparing prompt and calling OpenRouter...") + prompt = GENERATE_PAPER_PROMPT.format( + format_config=json.dumps(paper.format_config), + context_chunks=combined_context + ) + + messages = [ + {"role": "system", "content": "You are a professional exam paper generator."}, + {"role": "user", "content": prompt} + ] + + full_response = "" + chunk_count = 0 + print(f"ā³ [LLM] Streaming response (this may take 30-90s)...") + + async for chunk in open_router_client.stream_chat(messages): + full_response += chunk + chunk_count += 1 + if chunk_count % 50 == 0: + print(f"šŸ“„ [LLM] Received {chunk_count} chunks...") + # Robust Cancellation Check + async with SessionLocal() as check_db: + check_res = await check_db.execute(select(Paper).where(Paper.id == paper_id)) + p_check = check_res.scalar_one_or_none() + if not p_check or p_check.status not in ["pending", "generating"]: + print(f"šŸ›‘ [STOPPED] Paper {paper_id} deleted or aborted. Cancelling generation.") + if job_id: + async with SessionLocal() as job_db: + j_res = await job_db.execute(select(Job).where(Job.id == job_id)) + j = j_res.scalar_one_or_none() + if j: + j.status = "done" + from datetime import datetime + j.completed_at = datetime.utcnow() + await job_db.commit() + return + + print(f"āœ… [LLM] Response complete ({len(full_response)} chars).") + + # 4. Parse JSON + print(f"šŸ“¦ [DEBUG] Cleaning and parsing JSON response...") + clean_json = full_response.strip() + if clean_json.startswith("```json"): + clean_json = clean_json[7:] + if clean_json.endswith("```"): + clean_json = clean_json[:-3] + + try: + questions = json.loads(clean_json) + print(f"✨ [DEBUG] Successfully parsed {len(questions)} questions.") + except json.JSONDecodeError as je: + print(f"āŒ [PARSE ERROR] Failed to parse LLM response as JSON.") + print(f"šŸ” [DEBUG] Raw response snippet: {clean_json[:500]}...") + raise je + + # 5. Save Output + print(f"šŸ’¾ [DEBUG] Saving paper output to database...") + new_output = PaperOutput( + paper_id=paper.id, + questions=questions, + include_answers=True, + include_explanations=True + ) + db.add(new_output) + + # Re-fetch to update status + res_upd = await db.execute(select(Paper).where(Paper.id == paper_id)) + paper_to_done = res_upd.scalar_one() + paper_to_done.status = "done" + + if job_id: + job.status = "done" + from datetime import datetime + job.completed_at = datetime.utcnow() + + await db.commit() + print(f"šŸ [TASK COMPLETE] Paper {paper_id} is READY.\n") + + except Exception as e: + print(f"šŸ’„ [ERROR] Paper Generation Failed: {str(e)}") + traceback.print_exc() + try: + async with SessionLocal() as err_db: + res_upd = await err_db.execute(select(Paper).where(Paper.id == paper_id)) + paper_to_fail = res_upd.scalar_one_or_none() + if paper_to_fail: + paper_to_fail.status = "failed" + + if job_id: + from ..models.job import Job + j_res = await err_db.execute(select(Job).where(Job.id == job_id)) + j = j_res.scalar_one_or_none() + if j: + j.status = "failed" + j.error = str(e) + from datetime import datetime + j.completed_at = datetime.utcnow() + + await err_db.commit() + print(f"šŸ“‰ [DB] Paper {paper_id} marked as FAILED.") + except: + pass + +async def reset_monthly_quotas(ctx): + """ + ARQ Cron job intended to run on the 1st of every month to reset the questions_used + counter for all free tier users. + """ + from sqlalchemy import update + from ..models.user import User + + print("\nšŸ”„ [CRON] Starting monthly quota reset...") + async with SessionLocal() as db: + try: + # Only reset users on the free plan, although resetting all is also fine + # if paid plan is truly 'unlimited' regardless of counter. + stmt = update(User).where(User.plan == "free").values(questions_used=0) + result = await db.execute(stmt) + await db.commit() + print(f"āœ… [CRON] Monthly quotas reset successfully. Rows affected: {result.rowcount}") + except Exception as e: + print(f"āŒ [CRON] Failed to reset quotas: {e}") + traceback.print_exc() diff --git a/context/architecture.md b/context/architecture.md index 5698961..536eace 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -12,8 +12,8 @@ | LLM Gateway | OpenRouter | Access to Claude 3.5 Sonnet, Gemini 1.5 Pro, etc. | | Auth | Better Auth | Self-hosted auth (Email + Google) | | Job Queue | ARQ + Redis | Async background jobs (Paper gen, extraction) | -| File Storage | Cloudflare R2 | S3-compatible file storage (Original PDFs + Exports) | -| PDF Parsing | pdfplumber | Text extraction from PDFs | +| File Storage | DigitalOcean Spaces | S3-compatible file storage (Original PDFs + Exports) | +| PDF Parsing | pdfium2 + Vision AI | Text extraction from PDFs (Capped at 12 pages) | | PDF Output | WeasyPrint + Jinja2 | HTML to PDF rendering | ## System Boundaries @@ -21,13 +21,13 @@ - `backend/app/routers/` — API endpoint handlers and request validation. - `backend/app/services/` — Business logic (extraction, solver, generator, storage). - `backend/app/llm/` — OpenRouter client wrappers and prompt templates. -- `backend/workers/` — ARQ background task definitions for long-running jobs. +- `backend/app/workers/` — ARQ background task definitions for long-running jobs. - `frontend/src/api/` — API clients and React Query hooks. - `frontend/src/components/` — UI components (Tailwind + Theme support). ## Storage Model -- **PostgreSQL**: Metadata for users, resources, extracted text, questions, answers, papers, and jobs. +- **PostgreSQL**: Metadata for users, resources, extracted text, questions, answers, papers, and **background jobs** (audit trail). - **DigitalOcean Spaces**: Original uploaded PDFs and generated PDF exports. - **Redis**: Job queue state and application caching. - **Vector Storage**: *REMOVED for MVP*. Documents are passed directly to LLM context. @@ -44,6 +44,8 @@ 2. **Automatic Delivery Mode**: - Small Q&A responses use **Stream** mode (SSE). - Complex tasks (Paper Generation) use **Background** mode (ARQ) automatically. -3. **On-Demand PDF**: PDFs are generated and cached in R2 only when a user requests a download. -4. **Quota First**: Check `questions_used` before calling LLM APIs. -5. **OpenRouter Priority**: Use models with large context windows to handle full document text. +3. **On-Demand PDF**: PDFs are generated and cached in DigitalOcean only when a user requests a download. +4. **Persistent Context**: Chat session resource selections are stored in the database (`selected_resource_ids`). +5. **URL Source of Truth**: Active session IDs (Solver/Generator) are driven by the browser URL path. +6. **Quota First**: Check `questions_used` and monthly paper limits before calling LLM APIs. +7. **OpenRouter Priority**: Use models with large context windows to handle full document text. diff --git a/context/code-standards.md b/context/code-standards.md index 9c35dd8..50be04e 100644 --- a/context/code-standards.md +++ b/context/code-standards.md @@ -28,8 +28,10 @@ - Automatic routing: Use a consistent header or response field to notify the frontend if a job is moved to background processing. - All routes must be protected by authentication. +- **Fail-Fast**: Validate resource ownership and selection counts before starting long-running tasks. ## Data and Storage - Extracted text from resources is stored in the `resources` table (or a related `resource_content` table) as `TEXT`. - PDF generation uses Jinja2 templates for full layout control. +- **Job Audit**: Every ARQ worker task must update its corresponding record in the `jobs` table to track status (`queued`, `running`, `done`, `failed`). diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 9a20a37..aae7e7a 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -2,11 +2,11 @@ ## Current Phase -- Phase 4: PYQ Solver (Ready for Review) +- Phase 5: Sample Paper Generator (100% Core Complete) ## Current Goal -- Implement AI-powered question answering with document context and streaming. +- Deploy to Hetzner VPS and finalize production infrastructure. ## Completed @@ -26,27 +26,47 @@ - SSE (Server-Sent Events) backend routing. - Real-time chat interface with markdown rendering. - Multi-resource context selection. + - **Persistent Context**: Resource selections saved per session. + - **URL Routing**: Unique URLs for each chat session. +- **Phase 5: Sample Paper Generator**: + - **Backend**: Pydantic schemas, `papers` router, and full background generation logic. + - **Worker**: `generate_paper_task` with detailed debug logging and robust cancellation. + - **Frontend**: Interactive Generator dashboard with multi-resource selection. + - **Format Detection**: AI-powered extraction of exam patterns, editable via UI. + - **PDF Export**: Dual export modes (Study Guide & Question Paper) via WeasyPrint + Jinja2. + - **Persistent Links**: Generated PDFs are securely stored and directly accessible. + - **URL Routing**: Unique URLs for each generated paper. + - **Task Logging**: Comprehensive background job tracking in the `jobs` database table. + - **Quota Enforcement**: Backend limits applied for questions, resources, and papers. Monthly ARQ cron job implemented. +- **Real-Time Dashboard**: + - Comprehensive usage metrics and visual progress bars. + - Live tracking of background generation tasks with loaders and status indicators. + - Paper metadata (resource/question counts) displayed inline. ## In Progress -- Verification and testing of the end-to-end Solver flow. +- VPS Deployment (Hetzner, Nginx, SSL). ## Next Up -1. **Phase 5: Sample Paper Generator**: - - Automatic format detection. - - JSON paper generation. +1. **Nginx & SSL**: Configure reverse proxy and Let's Encrypt for secure HTTPS access. +2. **Production `.env`**: Finalize secrets and cloud storage paths. +3. **Smoke Testing**: Run a full end-to-end test on the live server. -## Open Questions +## Deferred (Post-Launch) -- **Context Window**: For very large documents, we might need a basic truncation strategy until we implement RAG. +- Mobile App / PWA Support. +- Math/LaTeX rendering. +- Payment Gateway (Razorpay). ## Architecture Decisions - **SSE (Server-Sent Events)**: Chosen for real-time streaming of LLM tokens. - **No RAG**: Full document text passed to prompt for better accuracy in small-to-medium documents. +- **Independent Mutations**: PDF exports split into separate loading states for UX. ## Session Notes -- Phase 4 implemented on `feature/pyq-solver`. -- Users can now select resources and ask questions in real-time. +- Phase 5 is fully complete including all polish requests (rename, delete, abort, persistent context). +- UI bug fixed: Modal footer overflow and scrollbar aesthetics. +- Deployment prioritized to ensure end-to-end functionality on live server. diff --git a/context/project-overview.md b/context/project-overview.md index eb7faf1..5c067e1 100644 --- a/context/project-overview.md +++ b/context/project-overview.md @@ -33,11 +33,19 @@ A web application designed for students (K-12 and college) to upload their study - AI-powered answering using OpenRouter (Claude/Gemini). - Streaming responses (SSE) for real-time interaction. - Document-based context (passing text directly to the prompt). +- **Persistent Context**: Remembers active document selections per session. +- **Deep Linking**: URL-based routing for direct session access. ### Sample Paper Generator - Automatic format detection from uploaded past papers. -- Customizable paper configurations (MCQ, Short, Long questions). -- On-demand PDF generation with togglable answers and explanations. +- Customizable paper configurations (MCQ, Short, Long questions), editable in UI. +- On-demand PDF generation with dual modes (Full Study Guide vs. Clean Question Paper). +- Generation management: Rename, Delete, and Abort in-progress tasks. + +### Real-Time Dashboard +- Live activity tracking with background generation status. +- Comprehensive usage metrics (Questions, Resources, Papers). +- Visual quota bars and "Remaining" credit indicators. ## Scope diff --git a/context/ui-context.md b/context/ui-context.md index 0f96c84..a4188bc 100644 --- a/context/ui-context.md +++ b/context/ui-context.md @@ -40,17 +40,21 @@ The application supports both **Light and Dark modes**. - **TailwindCSS** with `dark:` variants. - **shadcn/ui** or **Radix UI** primitives for accessible, themeable components. - Icons: **Lucide React**. +- **Utilities**: `.no-scrollbar` (Custom utility to hide scrollbar tracks while maintaining scroll functionality). ## Layout Patterns - **Standard Dashboard**: Left navigation, main content area. - **Dual Pane Solver**: Question input and scrollable context/answer area. +- **URL Deep Linking**: Browser URL path drives the active session ID (Solver/Generator). - **Responsive Design**: Mobile-friendly sidebar (hamburger menu on small screens). ## Icons - `Sun` / `Moon`: Theme toggling - `FileText`: Resources -- `Zap`: Solver -- `FileEdit`: Generator +- `Zap`: Solver (Questions) +- `FileEdit`: Generator (Papers) - `Download`: Export +- `Trash2`: Abort / Delete +- `Edit2`: Rename diff --git a/frontend/i7.png b/frontend/i7.png new file mode 100644 index 0000000..94b3c62 Binary files /dev/null and b/frontend/i7.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49bbbd1..0f1e642 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,19 @@ -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate, Link } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'react-hot-toast'; import Login from './pages/Login'; import Register from './pages/Register'; import Resources from './pages/Resources'; import Solver from './pages/Solver'; +import Generator from './pages/Generator'; import ProtectedRoute from './components/ProtectedRoute'; import Layout from './components/Layout'; import { useAuthStore } from './store/authStore'; import { useQuery } from '@tanstack/react-query'; import api from './api/auth'; +import { resourcesApi } from './api/resources'; +import { papersApi } from './api/papers'; import { useEffect } from 'react'; import { TooltipProvider } from "@/components/ui/tooltip" @@ -23,7 +26,19 @@ import { } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" -import { LayoutDashboard, Zap, FileText, CheckCircle2 } from "lucide-react" +import { + LayoutDashboard, + Zap, + FileText, + CheckCircle2, + FileEdit, + Clock, + XCircle, + Loader2, + ChevronRight, + ArrowRight +} from "lucide-react" +import { cn } from './lib/utils'; const queryClient = new QueryClient(); @@ -31,6 +46,7 @@ function Dashboard() { const storeUser = useAuthStore((state) => state.user); const setAuth = useAuthStore((state) => state.setAuth); const token = useAuthStore((state) => state.token); + const navigate = useNavigate(); const { data: user, isLoading } = useQuery({ queryKey: ['me'], @@ -40,6 +56,19 @@ function Dashboard() { }, }); + const { data: resources } = useQuery({ + queryKey: ['resources'], + queryFn: resourcesApi.list, + }); + + const { data: papers } = useQuery({ + queryKey: ['papers'], + queryFn: papersApi.list, + refetchInterval: (query) => { + return query.state.data?.some((p: any) => p.status === 'pending' || p.status === 'generating') ? 3000 : false; + } + }); + // Sync store when data changes useEffect(() => { if (user && token) { @@ -49,6 +78,23 @@ function Dashboard() { const displayUser = user || storeUser; + const isPaid = displayUser?.plan === 'paid'; + const limits = { + questions: isPaid ? 'Unlimited' : 30, + resources: isPaid ? 'Unlimited' : 3, + papers: isPaid ? 'Unlimited' : 3, + }; + + const getProgress = (used: number, limit: number | string) => { + if (limit === 'Unlimited') return 0; + return Math.min((used / (limit as number)) * 100, 100); + }; + + const getRemaining = (used: number, limit: number | string) => { + if (limit === 'Unlimited') return 'Unlimited'; + return Math.max((limit as number) - used, 0); + }; + if (isLoading && !storeUser) { return (
@@ -60,10 +106,12 @@ function Dashboard() { ); } + const recentPapers = papers?.slice(0, 5) || []; + return ( -
+
-

+

Dashboard

@@ -74,69 +122,229 @@ function Dashboard() { -
+
{/* Plan Status */}
- Account Plan - + Account Plan + {displayUser?.plan} - ACTIVE -

You are currently on the {displayUser?.plan} tier with standard limits.

+ ACTIVE
- {/* Usage Stats */} - + {/* Questions Answered */} +
- Usage Activity - - {displayUser?.questions_used} Questions Answered + Questions Answered + + {displayUser?.questions_used || 0} - -
-
+ +
+
- Quota: {displayUser?.questions_used} / 30 +
+

Used: {displayUser?.questions_used || 0} / {limits.questions}

+

{getRemaining(displayUser?.questions_used || 0, limits.questions)} Left

+
-
-
- - -
- + {/* Resources Uploaded */} + +
+ +
+ + Resources Uploaded + + {resources?.length || 0} + + + +
+
+
- Manage Resources - Upload notes, syllabi and past papers to train your tutor. - - - - - -
- +
+

Used: {resources?.length || 0} / {limits.resources}

+

{getRemaining(resources?.length || 0, limits.resources)} Left

- Open AI Solver - Start a new chat session and get answers grounded in your data. - - +
+ +
+ + {/* Papers Generated */} + +
+ +
+ + Papers Generated + + {papers?.length || 0} + + + +
+
+
+
+
+

Used: {papers?.length || 0} / {limits.papers}

+

{getRemaining(papers?.length || 0, limits.papers)} Left

+
+
+ + +
+ +
+ {/* Quick Actions */} +
+

Quick Actions

+
+ navigate('/resources')} + className="border-dashed bg-muted/20 hover:bg-muted/30 transition-all cursor-pointer group hover:border-primary/50" + > + +
+ +
+
+ Upload Resources + Add new study materials +
+ +
+
+ + navigate('/solver')} + className="border-dashed bg-muted/20 hover:bg-muted/30 transition-all cursor-pointer group hover:border-yellow-500/50" + > + +
+ +
+
+ Ask AI Tutor + Get answers from your notes +
+ +
+
+ + navigate('/generator')} + className="border-dashed bg-muted/20 hover:bg-muted/30 transition-all cursor-pointer group hover:border-purple-500/50" + > + +
+ +
+
+ Generate Paper + Create a new mock exam +
+ +
+
+
+
+ + {/* Recent Activity */} +
+
+

Recent Generations

+ + View all + +
+ +
+ {recentPapers.map((paper: any) => ( + navigate(`/generator/${paper.id}`)} + > + +
+
+ {paper.status === 'done' ? : + paper.status === 'failed' ? : + } +
+
+

{paper.title}

+
+ + {new Date(paper.created_at).toLocaleDateString()} + + + {paper.status} + +
+
+
+ +
+
+ {paper.resource_count || 0} + Sources +
+
+ {paper.question_count || 0} + Questions +
+ +
+
+
+ ))} + + {recentPapers.length === 0 && ( +
+
+ +
+
+

No generations yet

+

Your mock exams will appear here once generated.

+
+
+ )} +
+
); @@ -174,7 +382,7 @@ function App() { } /> @@ -183,9 +391,18 @@ function App() { } /> + + + + + + } + /> {/* Fallbacks */} - } /> } /> diff --git a/frontend/src/api/papers.ts b/frontend/src/api/papers.ts new file mode 100644 index 0000000..4dad92a --- /dev/null +++ b/frontend/src/api/papers.ts @@ -0,0 +1,48 @@ +import api from './auth'; + +export const papersApi = { + list: async () => { + const response = await api.get('/papers'); + return response.data; + }, + + get: async (id: string) => { + const response = await api.get(`/papers/${id}`); + return response.data; + }, + + getOutput: async (id: string) => { + const response = await api.get(`/papers/${id}/output`); + return response.data; + }, + + create: async (data: any) => { + const response = await api.post('/papers', data); + return response.data; + }, + + update: async (id: string, data: { title: string }) => { + const response = await api.patch(`/papers/${id}`, data); + return response.data; + }, + + detectFormat: async (resourceId: string) => { + const response = await api.post('/papers/detect-format', { resource_id: resourceId }); + return response.data; + }, + + delete: async (id: string) => { + const response = await api.delete(`/papers/${id}`); + return response.data; + }, + + toggleOutput: async (id: string, data: any) => { + const response = await api.patch(`/papers/${id}/output`, data); + return response.data; + }, + + getPdf: async (id: string, mode: string = 'full') => { + const response = await api.get(`/papers/${id}/pdf`, { params: { mode } }); + return response.data; + } +}; diff --git a/frontend/src/api/solver.ts b/frontend/src/api/solver.ts index 8ab101f..db158b2 100644 --- a/frontend/src/api/solver.ts +++ b/frontend/src/api/solver.ts @@ -3,6 +3,7 @@ import api, { API_URL } from './auth'; export interface ChatSession { id: string; title: string; + selected_resource_ids: string[]; created_at: string; updated_at: string; } @@ -31,7 +32,7 @@ export const solverApi = { const response = await api.delete(`/solver/sessions/${id}`); return response.data; }, - updateSession: async (id: string, data: { title: string }) => { + updateSession: async (id: string, data: { title?: string, selected_resource_ids?: string[] }) => { const response = await api.patch(`/solver/sessions/${id}`, data); return response.data; }, diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cec7a77 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/frontend/src/index.css b/frontend/src/index.css index 610990c..fd2f9bc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -119,3 +119,15 @@ @apply bg-background text-foreground; } } + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } +} diff --git a/frontend/src/pages/Generator.tsx b/frontend/src/pages/Generator.tsx new file mode 100644 index 0000000..b82023e --- /dev/null +++ b/frontend/src/pages/Generator.tsx @@ -0,0 +1,795 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import { resourcesApi } from '../api/resources'; +import { papersApi } from '../api/papers'; +import { + FileEdit, + Plus, + Loader2, + Settings2, + CheckCircle2, + XCircle, + Clock, + ChevronRight, + BookOpen, + FileText, + Brain, + Download, + Eye, + EyeOff, + AlertCircle, + Zap, + Trash2, + Edit2, + Check +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Checkbox } from "@/components/ui/checkbox" +import { toast } from 'react-hot-toast'; +import api from '@/api/auth'; + +export default function Generator() { + const queryClient = useQueryClient(); + const { paperId } = useParams(); + const navigate = useNavigate(); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [title, setTitle] = useState(''); + const [selectedResources, setSelectedResources] = useState<{id: string, role: string}[]>([]); + const [isDetecting, setIsDetecting] = useState(false); + const [formatConfig, setFormatConfig] = useState({ + mcq: 0, + short: 0, + long: 0, + mcq_marks: 1, + short_marks: 5, + long_marks: 10 + }); + const [activePaperId, setActivePaperId] = useState(paperId || null); + const [editingPaperId, setEditingPaperId] = useState(null); + const [newPaperTitle, setNewPaperTitle] = useState(''); + + // Sync state with URL + useEffect(() => { + if (paperId) { + setActivePaperId(paperId); + } else { + setActivePaperId(null); + } + }, [paperId]); + + // Handle paper selection + const handleSelectPaper = (id: string) => { + if (id !== activePaperId) { + navigate(`/generator/${id}`); + } + }; + + // --- QUERIES --- + const { data: user } = useQuery({ + queryKey: ['me'], + queryFn: async () => { + const response = await api.get('/auth/me'); + return response.data; + }, + }); + + const { data: resources } = useQuery({ + queryKey: ['resources'], + queryFn: resourcesApi.list, + }); + + const { data: papers, isLoading: papersLoading } = useQuery({ + queryKey: ['papers'], + queryFn: papersApi.list, + refetchInterval: (query) => { + return query.state.data?.some((p: any) => p.status === 'pending' || p.status === 'generating') ? 3000 : false; + } + }); + + const activePaper = papers?.find((p: any) => p.id === activePaperId); + + const { data: activeOutput, isLoading: outputLoading } = useQuery({ + queryKey: ['paper-output', activePaperId], + queryFn: () => activePaperId ? papersApi.getOutput(activePaperId) : Promise.resolve(null), + enabled: !!activePaperId && activePaper?.status === 'done', + }); + + const readyResources = resources?.filter(r => r.status === 'ready') || []; + + const isPatternEmpty = (formatConfig?.mcq || 0) + (formatConfig?.short || 0) + (formatConfig?.long || 0) === 0; + + // --- MUTATIONS --- + const detectFormatMutation = useMutation({ + mutationFn: (resourceId: string) => papersApi.detectFormat(resourceId), + onSuccess: (data) => { + setFormatConfig(data); + toast.success('Format detected successfully!'); + }, + onSettled: () => setIsDetecting(false), + }); + + const createPaperMutation = useMutation({ + mutationFn: (data: any) => papersApi.create(data), + onSuccess: (newPaper) => { + queryClient.invalidateQueries({ queryKey: ['papers'] }); + setIsCreateOpen(false); + setTitle(''); + setSelectedResources([]); + setFormatConfig(null); + navigate(`/generator/${newPaper.id}`); + toast.success('Paper generation started!'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.detail || 'Failed to create paper.'); + } + }); + + const toggleSettingsMutation = useMutation({ + mutationFn: ({ id, data }: { id: string, data: any }) => papersApi.toggleOutput(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['paper-output', activePaperId] }); + } + }); + + const exportFullPdfMutation = useMutation({ + mutationFn: (id: string) => papersApi.getPdf(id, 'full'), + onSuccess: (data) => { + if (data.url) { + window.open(data.url, '_blank'); + toast.success('Study Guide generated!'); + } + }, + onError: () => toast.error('Failed to generate Study Guide.') + }); + + const exportQuestionsPdfMutation = useMutation({ + mutationFn: (id: string) => papersApi.getPdf(id, 'questions_only'), + onSuccess: (data) => { + if (data.url) { + window.open(data.url, '_blank'); + toast.success('Question Paper generated!'); + } + }, + onError: () => toast.error('Failed to generate Question Paper.') + }); + + const renamePaperMutation = useMutation({ + mutationFn: ({ id, title }: { id: string; title: string }) => papersApi.update(id, { title }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['papers'] }); + setEditingPaperId(null); + toast.success('Paper renamed successfully'); + }, + onError: () => { + toast.error('Failed to rename paper.'); + } + }); + + const deletePaperMutation = useMutation({ + mutationFn: (id: string) => papersApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['papers'] }); + setActivePaperId(null); + navigate('/generator'); + toast.success('Paper deleted successfully'); + }, + onError: () => { + toast.error('Failed to delete paper.'); + } + }); + + const startRenamingPaper = (id: string, currentTitle: string) => { + setEditingPaperId(id); + setNewPaperTitle(currentTitle); + }; + + const handleRenamePaper = (id: string) => { + if (!newPaperTitle.trim()) return; + renamePaperMutation.mutate({ id, title: newPaperTitle }); + }; + + // --- HANDLERS --- + const handleResourceToggle = (id: string) => { + setSelectedResources(prev => { + const exists = prev.find(r => r.id === id); + if (exists) return prev.filter(r => r.id !== id); + return [...prev, { id, role: 'notes' }]; + }); + }; + + const handleRoleChange = (id: string, role: string) => { + setSelectedResources(prev => prev.map(r => r.id === id ? { ...r, role } : r)); + }; + + const handleDetectFormat = (id: string) => { + setIsDetecting(true); + detectFormatMutation.mutate(id); + }; + + const handleCreatePaper = () => { + if (!title || selectedResources.length === 0 || isPatternEmpty) { + toast.error('Please fill in all fields and ensure the pattern is not empty.'); + return; + } + + // --- QUOTA CHECK --- + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const monthlyPapers = papers?.filter((p: any) => new Date(p.created_at) >= startOfMonth) || []; + + if (user?.plan === 'free' && monthlyPapers.length >= 3) { + toast.error('limit exceed, upgrade to continue'); + return; + } + + createPaperMutation.mutate({ + title, + resources: selectedResources.map(r => ({ resource_id: r.id, role: r.role })), + format_config: formatConfig, + }); + }; + + return ( +
+
+
+

+ + Paper Generator +

+

+ Generate custom exam papers from your study materials. +

+
+ + + + + + + + New Sample Paper + + Select your materials and define the exam pattern. + + + +
+
+
+ + setTitle(e.target.value)} + /> +
+ + + +
+
+ + {selectedResources.length} Selected +
+ +
+ {readyResources.map(res => ( +
r.id === res.id) + ? "border-primary bg-primary/5 shadow-sm" + : "border-border hover:border-primary/50" + )} + > +
+ r.id === res.id)} + onCheckedChange={() => handleResourceToggle(res.id)} + /> +
+ + + {res.type} + +
+
+ + {selectedResources.find(r => r.id === res.id) && ( +
+ + {res.type === 'past_paper' && ( + + )} +
+ )} +
+ ))} + {readyResources.length === 0 && ( +
+

No ready resources found. Upload some first.

+
+ )} +
+
+ + + +
+
+ +
+ {isDetecting && Analyzing...} + Editable +
+
+ +
+
+ +
+ setFormatConfig({...formatConfig, mcq: parseInt(e.target.value) || 0})} + className="pl-8 font-bold" + /> + +
+
+
+ +
+ setFormatConfig({...formatConfig, short: parseInt(e.target.value) || 0})} + className="pl-8 font-bold" + /> + +
+
+
+ +
+ setFormatConfig({...formatConfig, long: parseInt(e.target.value) || 0})} + className="pl-8 font-bold" + /> + +
+
+
+ +
+ +

+ To auto-fill this, select a "Past Paper" in Step 1 and click Detect Pattern. + You can also manually enter the numbers above. +

+
+
+
+
+ + + + + +
+
+
+ +
+ {/* Paper List Sidebar */} +
+

Recent Generations

+ +
+ {papersLoading &&
} + {papers?.map((paper: any) => ( + handleSelectPaper(paper.id)} + > + +
+
+ {paper.status === 'done' ? : + paper.status === 'failed' ? : + } +
+
+ {editingPaperId === paper.id ? ( +
e.stopPropagation()}> + setNewPaperTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenamePaper(paper.id); + if (e.key === 'Escape') setEditingPaperId(null); + }} + autoFocus + /> + +
+ ) : ( + <> +

{paper.title}

+

+ {new Date(paper.created_at).toLocaleDateString()} +

+ + )} +
+
+ + {editingPaperId !== paper.id && ( +
e.stopPropagation()}> + + +
+ )} + +
+
+ ))} + {!papersLoading && papers?.length === 0 && ( +
+

No papers generated yet.

+
+ )} +
+
+
+ + {/* Paper Content Area */} +
+ {activePaperId ? ( +
+ {/* Header Card */} + {activePaper && ( + + +
+ {activePaper.title} +
+
+ + {activePaper.status} + +
+
+
+
+ + + {/* Full Version (Study Guide) */} + {activeOutput?.pdf_url ? ( +
+ + +
+ ) : ( + + )} + + {/* Questions Only Version */} + {activeOutput?.question_pdf_url ? ( + + ) : ( + + )} +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* Question List */} +
+ {outputLoading && ( +
+ +

Retrieving your custom paper...

+
+ )} + + {!outputLoading && activeOutput?.questions?.map((q: any, idx: number) => ( + + +
+
+ + QUESTION {idx + 1} + + + {q.type} • {q.marks} MARKS + +
+ {q.question_text} +
+
+ + {q.topic} + +
+
+ + {q.type === 'mcq' && q.options && ( +
+ {q.options.map((opt: string, i: number) => ( +
+ {opt} +
+ ))} +
+ )} + + {activeOutput.include_answers && q.type !== 'mcq' && ( +
+
+ + Model Answer +
+

+ {q.answer} +

+
+ )} + + {activeOutput.include_explanations && q.explanation && ( +
+
+ + AI Explanation +
+

+ {q.explanation} +

+
+ )} +
+
+ ))} + + {!outputLoading && !activeOutput && activePaper?.status !== 'failed' && ( +
+
+ +
+
+

Paper is Generating

+

Our AI is crafting your exam based on your materials. This usually takes 30-60 seconds.

+
+
+ )} + + {!outputLoading && !activeOutput && activePaper?.status === 'failed' && ( +
+
+ +
+
+

Generation Failed

+

Something went wrong while generating this paper. Please check the worker logs and try again.

+
+
+ )} +
+
+ ) : ( +
+
+ +
+
+

Ready to Practice?

+

Select a generated paper from the sidebar or click the button above to create a brand new one from your materials.

+
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Solver.tsx b/frontend/src/pages/Solver.tsx index f94c855..4b58498 100644 --- a/frontend/src/pages/Solver.tsx +++ b/frontend/src/pages/Solver.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; import { resourcesApi } from '../api/resources'; import { solverApi, type ChatMessage } from '../api/solver'; import { @@ -40,17 +41,38 @@ import { export default function Solver() { const queryClient = useQueryClient(); + const { sessionId } = useParams(); + const navigate = useNavigate(); + const [question, setQuestion] = useState(''); const [selectedResources, setSelectedResources] = useState([]); const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); - const [activeSessionId, setActiveSessionId] = useState(null); + const [activeSessionId, setActiveSessionId] = useState(sessionId || null); const [editingSessionId, setEditingSessionId] = useState(null); const [newSessionTitle, setNewSessionTitle] = useState(''); const [showContext, setShowContext] = useState(true); const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(false); const scrollRef = useRef(null); + // Sync state with URL + useEffect(() => { + if (sessionId) { + setActiveSessionId(sessionId); + } else { + setActiveSessionId(null); + setMessages([]); + } + }, [sessionId]); + + // Handle session selection + const handleSelectSession = (id: string) => { + if (id !== activeSessionId) { + setMessages([]); + navigate(`/solver/${id}`); + } + }; + // --- QUERIES --- const { data: resources } = useQuery({ queryKey: ['resources'], @@ -75,7 +97,7 @@ export default function Solver() { mutationFn: solverApi.createSession, onSuccess: (newSession) => { queryClient.invalidateQueries({ queryKey: ['sessions'] }); - setActiveSessionId(newSession.id); + navigate(`/solver/${newSession.id}`); setMessages([]); toast.success('New session created'); }, @@ -86,8 +108,7 @@ export default function Solver() { onSuccess: (_, deletedId) => { queryClient.invalidateQueries({ queryKey: ['sessions'] }); if (activeSessionId === deletedId) { - setActiveSessionId(null); - setMessages([]); + navigate('/solver'); } toast.success('Session deleted'); }, @@ -102,6 +123,14 @@ export default function Solver() { }, }); + const updateResourcesMutation = useMutation({ + mutationFn: ({ id, selected_resource_ids }: { id: string; selected_resource_ids: string[] }) => + solverApi.updateSession(id, { selected_resource_ids }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); + // Sync history to messages state useEffect(() => { if (history) { @@ -109,6 +138,18 @@ export default function Solver() { } }, [history]); + // Sync selected resources from active session + useEffect(() => { + if (activeSessionId && sessions) { + const activeSession = sessions.find((s: any) => s.id === activeSessionId); + if (activeSession) { + setSelectedResources(activeSession.selected_resource_ids || []); + } + } else { + setSelectedResources([]); + } + }, [activeSessionId, sessions]); + useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; @@ -167,7 +208,8 @@ export default function Solver() { }); } if (data.session_id && !activeSessionId) { - setActiveSessionId(data.session_id); + // Important: Use navigate with replace to update the URL without adding a broken back-stack entry + navigate(`/solver/${data.session_id}`, { replace: true }); queryClient.invalidateQueries({ queryKey: ['sessions'] }); } if (data.error) toast.error(data.error); @@ -186,9 +228,19 @@ export default function Solver() { }; const toggleResource = (id: string) => { - setSelectedResources(prev => - prev.includes(id) ? prev.filter(r => r !== id) : [...prev, id] - ); + const newSelection = selectedResources.includes(id) + ? selectedResources.filter(r => r !== id) + : [...selectedResources, id]; + + setSelectedResources(newSelection); + + // Persist to backend if we have an active session + if (activeSessionId) { + updateResourcesMutation.mutate({ + id: activeSessionId, + selected_resource_ids: newSelection + }); + } }; const startRenamingSession = (id: string, currentTitle: string) => { @@ -251,7 +303,7 @@ export default function Solver() {
) : (
) : (