diff --git a/backend/alembic/versions/875afd8e0b2e_add_summary_to_document.py b/backend/alembic/versions/875afd8e0b2e_add_summary_to_document.py new file mode 100644 index 0000000..f14c573 --- /dev/null +++ b/backend/alembic/versions/875afd8e0b2e_add_summary_to_document.py @@ -0,0 +1,32 @@ +"""Add summary to document + +Revision ID: 875afd8e0b2e +Revises: 6e2d1d2b7c4a +Create Date: 2026-03-28 16:00:35.361232 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '875afd8e0b2e' +down_revision: Union[str, Sequence[str], None] = '6e2d1d2b7c4a' +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('documents', sa.Column('summary', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('documents', 'summary') + # ### end Alembic commands ### diff --git a/backend/app/api/document.py b/backend/app/api/document.py index e144319..4428ad6 100644 --- a/backend/app/api/document.py +++ b/backend/app/api/document.py @@ -10,8 +10,9 @@ from app.utils.utils import get_page_count from app.core.deps import get_current_user from app.repositories.document_repository import DocumentRepository -from app.schemas.document import DocumentOut +from app.schemas.document import DocumentOut, DocumentSummaryOut from app.utils.utils import process_document_task +from app.services.document_service import DocumentService router = APIRouter(prefix="/documents", tags=["document"]) @@ -88,3 +89,36 @@ def delete_document( if DocumentRepository.delete_document(db, document_id): return {"message": "Document and all related data deleted"} +@router.post("/{document_id}/summary", response_model=DocumentSummaryOut) +def generate_document_summary_endpoint( + document_id: UUID, + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + document = DocumentRepository.get_document_by_id(db, document_id) + if not document or document.user_id != user.id: + raise HTTPException(status_code=404, detail="Document not found") + + if document.summary: + return {"summary": document.summary, "message": "Summary already created"} + + try: + summary = DocumentService.generate_document_summary(db, document_id) + return {"summary": summary, "message": "Summary generated successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{document_id}/summary", response_model=DocumentSummaryOut) +def get_document_summary_endpoint( + document_id: UUID, + db: Session = Depends(get_db), + user: User = Depends(get_current_user) +): + document = DocumentRepository.get_document_by_id(db, document_id) + if not document or document.user_id != user.id: + raise HTTPException(status_code=404, detail="Document not found") + + if not document.summary: + raise HTTPException(status_code=404, detail="Summary not generated yet") + + return {"summary": document.summary, "message": "Summary retrieved from storage"} diff --git a/backend/app/models/document.py b/backend/app/models/document.py index 1778250..14a4eaa 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -2,7 +2,7 @@ import enum -from sqlalchemy import Column, ForeignKey, Index, Integer, String, DateTime, Enum, func +from sqlalchemy import Column, ForeignKey, Index, Integer, String, DateTime, Enum, func, Text from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID @@ -44,6 +44,8 @@ class Document(Base): nullable=False ) + summary = Column(Text, nullable=True) + user = relationship("User", back_populates="documents") chunks = relationship("Chunk", back_populates="document", cascade="all, delete") ai_interactions = relationship("AIInteraction", back_populates="document", cascade="all, delete-orphan") diff --git a/backend/app/repositories/document_repository.py b/backend/app/repositories/document_repository.py index 26bd2f6..9591570 100644 --- a/backend/app/repositories/document_repository.py +++ b/backend/app/repositories/document_repository.py @@ -3,6 +3,7 @@ from uuid import UUID from app.models.document import Document +from app.models.chunk import Chunk class DocumentRepository: @@ -54,3 +55,18 @@ def delete_document(db: Session, document_id: UUID) -> bool: db.delete(document) db.commit() return True + + @staticmethod + def update_document_summary(db: Session, document_id: UUID, summary: str) -> None: + document = db.query(Document).filter(Document.id == document_id).first() + + if not document: + raise ValueError(f"Document with id {document_id} not found") + + document.summary = summary + db.commit() + db.refresh(document) + + @staticmethod + def get_document_chunks(db: Session, document_id: UUID) -> list[Chunk]: + return db.query(Chunk).filter(Chunk.document_id == document_id).order_by(Chunk.chunk_index.asc()).all() diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index bb8e89d..cd3e7ec 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -10,6 +10,11 @@ class DocumentOut(BaseModel): filename: str pages: int status: str + summary: str | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) + +class DocumentSummaryOut(BaseModel): + summary: str + message: str | None = None diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index f4fb96e..afa6bec 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -1,6 +1,10 @@ import fitz # PyMuPDF from pptx import Presentation from io import BytesIO +from sqlalchemy.orm import Session +from uuid import UUID +from app.repositories.document_repository import DocumentRepository +from app.services.llm_service import LLMService class DocumentService: @@ -49,3 +53,28 @@ def _clean_text(text: str) -> str: text = text.replace("\x00", "") text = text.replace("\n\n", "\n") return text.strip() + + @staticmethod + def generate_document_summary(db: Session, document_id: UUID) -> str: + # Check if already generated + document = DocumentRepository.get_document_by_id(db, document_id) + if not document: + raise ValueError(f"Document {document_id} not found") + if document.summary: + return document.summary + + chunks = DocumentRepository.get_document_chunks(db, document_id) + if not chunks: + raise ValueError(f"No text extracted for document {document_id}") + + # Combine all parts + combined_text = "\n\n".join([chunk.text for chunk in chunks]) + + # Generate summary using LLM service + summary_markdown = LLMService.generate_summary(combined_text) + + # Save to DB + DocumentRepository.update_document_summary(db, document_id, summary_markdown) + + return summary_markdown + diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 8dc3e32..d9ba38d 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -32,6 +32,37 @@ def generate(cls, prompt: str) -> str: response.raise_for_status() - data = response.json() + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + + @classmethod + def generate_summary(cls, text: str) -> str: + system_prompt = ( + "You are an expert tutor. Provide a comprehensive, structured, and interactive summary of the provided text. " + "Use Markdown formatting, bold key terms, use bullet points, and clear headings. " + "Ensure everything a user needs to know to ace an exam on this topic is included. Be concise to save tokens but highly informative." + ) + + # Limiting input text length to avoid excessive token usage + max_chars = 120000 + truncated_text = text[:max_chars] if len(text) > max_chars else text + payload = { + "model": cls.MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Summarize this document comprehensively into key points:\n\n{truncated_text}"} + ], + "temperature": 0.3, + "max_tokens": 1500 + } + + response = requests.post( + cls.API_URL, + headers=cls.HEADERS, + json=payload, + timeout=180 + ) + + response.raise_for_status() + data = response.json() return data.get("choices", [{}])[0].get("message", {}).get("content", "") diff --git a/frontend/src/components/study-room/DocumentSummaryView.tsx b/frontend/src/components/study-room/DocumentSummaryView.tsx new file mode 100644 index 0000000..f5a0104 --- /dev/null +++ b/frontend/src/components/study-room/DocumentSummaryView.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { FileText, Check, Copy } from 'lucide-react'; +import { documentService, type DocumentSummary } from '../../services/document.service'; +import { Button } from '../ui/Button'; +import { LoadingView } from './LoadingView'; +import { Skeleton } from '../ui/Skeleton'; + +interface DocumentSummaryViewProps { + documentId: string; +} + +export const DocumentSummaryView: React.FC = ({ documentId }) => { + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (!documentId) return; + + const fetchOrGenerateSummary = async () => { + setIsLoading(true); + setError(null); + try { + // Try getting the existing summary first + try { + const data = await documentService.getSummary(documentId); + setSummary(data); + } catch (e: unknown) { + const err = e as { response?: { status?: number }, status?: number }; + // If not found (404), generate it + if (err.response?.status === 404 || err.status === 404) { + const generatedData = await documentService.generateSummary(documentId); + setSummary(generatedData); + } else { + throw e; + } + } + } catch (e: unknown) { + console.error('Failed to load summary:', e); + setError('Failed to generate or retrieve the document summary. Please try again later.'); + } finally { + setIsLoading(false); + } + }; + + if (!summary) { + fetchOrGenerateSummary(); + } + }, [documentId, summary]); + + const handleCopy = () => { + if (summary) { + navigator.clipboard.writeText(summary.summary); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } + }; + + return ( +
+
+ {isLoading ? ( +
+ +
+ + + + + + + +
+
+ ) : error ? ( +
+
+ +
+

{error}

+
+ ) : summary ? ( +
+ + {summary.summary} + +
+ ) : null} +
+ + {/* Footer */} + {summary && !isLoading && !error && ( +
+ +
+ )} +
+ ); +}; diff --git a/frontend/src/pages/StudyRoom.tsx b/frontend/src/pages/StudyRoom.tsx index 0699edc..9fb6c50 100644 --- a/frontend/src/pages/StudyRoom.tsx +++ b/frontend/src/pages/StudyRoom.tsx @@ -7,6 +7,7 @@ import { BookOpen, User, Bot, + FileText, } from 'lucide-react'; import { Badge } from '../components/ui'; import { cn } from '../lib/utils'; @@ -19,6 +20,7 @@ import { FloatingMenu } from '../components/study-room/FloatingMenu'; import { aiActions, type ActionKey } from '../components/study-room/constants'; import { ResponseRenderer } from '../components/study-room/ResponseRenderer'; import { TypingEffect } from '../components/study-room/TypingEffect'; +import { DocumentSummaryView } from '../components/study-room/DocumentSummaryView'; /* ── Configure PDF.js worker ── */ pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; @@ -87,6 +89,9 @@ const StudyRoom: React.FC = () => { }; }, []); + /* ── Right Panel Tab State ── */ + const [activeTab, setActiveTab] = useState<'chat' | 'summary'>('chat'); + /* ── Chat state ── */ const [messages, setMessages] = useState([]); const [isTyping, setIsTyping] = useState(false); @@ -357,26 +362,47 @@ const StudyRoom: React.FC = () => {
- {/* ── Right Panel: AI Chat ── */} + {/* ── Right Panel: AI Chat & Summary ── */}
- {/* Header */} -
-
-
- -
- - AI Tutor - -
- LIVE + {/* Header Tabs */} +
+ + +
- {/* Chat Area */} -
+ {/* Content Area */} + {activeTab === 'chat' ? ( + <> + {/* Chat Area */} +
{messages.length === 0 ? ( /* ── Empty State ── */
@@ -460,6 +486,12 @@ const StudyRoom: React.FC = () => { AI may make mistakes. Please verify important information.

+ + ) : ( +
+ {id && } +
+ )}
{/* ── Floating Action Menu ── */} diff --git a/frontend/src/services/document.service.ts b/frontend/src/services/document.service.ts index f6fd72c..0b8c942 100644 --- a/frontend/src/services/document.service.ts +++ b/frontend/src/services/document.service.ts @@ -9,6 +9,11 @@ export interface Document { user_id?: string; } +export interface DocumentSummary { + summary: string; + message: string; +} + export const documentService = { getDocuments: async (): Promise => { const response = await api.get('/documents/'); @@ -34,5 +39,15 @@ export const documentService = { deleteDocument: async (documentId: string): Promise => { await api.delete(`/documents/${documentId}`); + }, + + generateSummary: async (documentId: string): Promise => { + const response = await api.post(`/documents/${documentId}/summary`); + return response.data; + }, + + getSummary: async (documentId: string): Promise => { + const response = await api.get(`/documents/${documentId}/summary`); + return response.data; } };