From a0012db481ff0846c6d8bc584279fe432ec90994 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sat, 28 Mar 2026 17:02:46 +0300 Subject: [PATCH 1/8] feat(db): add summary column and migration --- .../875afd8e0b2e_add_summary_to_document.py | 32 +++++++++++++++++++ backend/app/models/document.py | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/875afd8e0b2e_add_summary_to_document.py 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/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") From f063a45819697ce057838a1abebaab81ba92abfc Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sat, 28 Mar 2026 17:02:49 +0300 Subject: [PATCH 2/8] feat(document): add repository and schema for summary --- backend/app/repositories/document_repository.py | 16 ++++++++++++++++ backend/app/schemas/document.py | 4 ++++ 2 files changed, 20 insertions(+) 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..f6ab59a 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -10,6 +10,10 @@ 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 From e99b56cf9317417e7bc5464c1a17e496c4a651c3 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sat, 28 Mar 2026 17:02:53 +0300 Subject: [PATCH 3/8] feat(document): implement summary generation services --- backend/app/services/document_service.py | 29 +++++++++++++++++++++ backend/app/services/llm_service.py | 33 +++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) 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", "") From 057c84da18fd1276b56aa12d6cec9edab2340f62 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sat, 28 Mar 2026 17:02:57 +0300 Subject: [PATCH 4/8] feat(api): add summary endpoints --- backend/app/api/document.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/app/api/document.py b/backend/app/api/document.py index e144319..6120383 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,33 @@ 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") + + try: + summary = DocumentService.generate_document_summary(db, document_id) + return {"summary": summary} + 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} From 0515be136df3a0be316aecdf8e2f02b8c7a60792 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sat, 28 Mar 2026 17:05:34 +0300 Subject: [PATCH 5/8] feat(api): inform user if summary already exists --- backend/app/api/document.py | 7 +++++-- backend/app/schemas/document.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/api/document.py b/backend/app/api/document.py index 6120383..4428ad6 100644 --- a/backend/app/api/document.py +++ b/backend/app/api/document.py @@ -99,9 +99,12 @@ def generate_document_summary_endpoint( 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} + return {"summary": summary, "message": "Summary generated successfully"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -118,4 +121,4 @@ def get_document_summary_endpoint( if not document.summary: raise HTTPException(status_code=404, detail="Summary not generated yet") - return {"summary": document.summary} + return {"summary": document.summary, "message": "Summary retrieved from storage"} diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index f6ab59a..cd3e7ec 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -17,3 +17,4 @@ class DocumentOut(BaseModel): class DocumentSummaryOut(BaseModel): summary: str + message: str | None = None From d48618edbc950692a3e8a6e5497cfd569eb41ec6 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sun, 29 Mar 2026 16:45:46 +0300 Subject: [PATCH 6/8] feat(document): add summary API endpoints to services --- frontend/src/services/document.service.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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; } }; From 6714ebf07186786bfceeff24cc275652d357f2b7 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sun, 29 Mar 2026 16:45:53 +0300 Subject: [PATCH 7/8] feat(ui): implement document summary view component --- .../study-room/DocumentSummaryView.tsx | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 frontend/src/components/study-room/DocumentSummaryView.tsx 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 && ( +
+ +
+ )} +
+ ); +}; From a1252dd4a608546f40fb328661bb778a95fad902 Mon Sep 17 00:00:00 2001 From: Samuel-Tefera Date: Sun, 29 Mar 2026 16:45:59 +0300 Subject: [PATCH 8/8] feat(ui): add tabbed navigation for chat and summary in study room --- frontend/src/pages/StudyRoom.tsx | 66 ++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 17 deletions(-) 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 ── */}