Download app/main.py from berohan/studyrag: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/spaces/berohan/studyrag/resolve/main/app/main.py
- Command line
-
hf download hf://spaces/berohan/studyrag/app/main.py
-
curl -L -o main.py https://huggingface.co/spaces/berohan/studyrag/resolve/main/app/main.py
12.7 kB
| import asyncio | |
| import json | |
| import logging | |
| import re | |
| import uuid | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from typing import AsyncGenerator | |
| from fastapi import FastAPI, File, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from app.config import settings | |
| from app.models.schemas import ( | |
| ChatMessage, | |
| DeleteDocumentResponse, | |
| FinalResponse, | |
| QueryRequest, | |
| RenameSessionRequest, | |
| ScrapeRequest, | |
| SessionMessagesResponse, | |
| SessionSummary, | |
| StatusResponse, | |
| SummarizeRequest, | |
| SummaryResponse, | |
| ) | |
| from app.services.history_service import HistoryService | |
| from app.services.rag_service import RAGService | |
| from app.utils.document_processor import ALLOWED_EXTENSIONS, DocumentProcessor | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") | |
| logger = logging.getLogger("studyson") | |
| async def lifespan(_: FastAPI): | |
| settings.upload_dir.mkdir(parents=True, exist_ok=True) | |
| settings.chroma_dir.mkdir(parents=True, exist_ok=True) | |
| logger.info("Studyson starting | model=%s | chroma=%s", settings.groq_model, settings.chroma_dir) | |
| # Warm the embedding model at startup so the first user request doesn't | |
| # pay the model-load cost (several seconds on a cold container). | |
| await asyncio.to_thread(rag_service.warm_up) | |
| logger.info("Embedding model warm | embed_model=%s", settings.embed_model) | |
| yield | |
| app = FastAPI( | |
| title="Studyson RAG API", | |
| description="Document QA and summarization using RAG (Groq + LlamaIndex + Chroma)", | |
| version="2.0.0", | |
| lifespan=lifespan, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| rag_service = RAGService() | |
| doc_processor = DocumentProcessor() | |
| history_service = HistoryService(settings.chroma_dir / settings.history_db_name) | |
| app.mount("/assets", StaticFiles(directory="static/assets"), name="assets") | |
| async def upload_document(file: UploadFile = File(...)): | |
| if not file.filename or not doc_processor.validate_file_type(file.filename): | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unsupported file type. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}", | |
| ) | |
| if file.size and file.size > settings.max_file_size: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"File exceeds {settings.max_file_size // (1024 * 1024)} MB limit", | |
| ) | |
| settings.upload_dir.mkdir(parents=True, exist_ok=True) | |
| safe_name = file.filename.replace("/", "_").replace("\\", "_") | |
| file_path = settings.upload_dir / safe_name | |
| try: | |
| content = await file.read() | |
| if len(content) > settings.max_file_size: | |
| raise HTTPException(status_code=400, detail="File exceeds size limit") | |
| file_path.write_bytes(content) | |
| text = await doc_processor.extract_text(file_path) | |
| cleaned_text = doc_processor.clean_text(text) | |
| if not cleaned_text.strip(): | |
| raise HTTPException(status_code=400, detail="No extractable text in file") | |
| await asyncio.to_thread(rag_service.add_document, cleaned_text, safe_name) | |
| return StatusResponse( | |
| status="success", | |
| message=f"Document '{safe_name}' indexed successfully", | |
| details={ | |
| "filename": safe_name, | |
| "text_length": len(cleaned_text), | |
| "indexed_documents": rag_service.get_indexed_documents(), | |
| }, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.exception("Upload failed for %s", file.filename) | |
| if file_path.exists(): | |
| file_path.unlink(missing_ok=True) | |
| raise HTTPException(status_code=500, detail=f"Error processing document: {e}") | |
| async def scrape_and_index(request: ScrapeRequest): | |
| try: | |
| title, text = await doc_processor.scrape_url(str(request.url)) | |
| cleaned_text = doc_processor.clean_text(text) | |
| if not cleaned_text.strip(): | |
| raise HTTPException(status_code=400, detail="No extractable text on page") | |
| await asyncio.to_thread(rag_service.add_document, cleaned_text, title) | |
| return StatusResponse( | |
| status="success", | |
| message="URL content indexed successfully", | |
| details={ | |
| "url": str(request.url), | |
| "title": title, | |
| "text_length": len(cleaned_text), | |
| "indexed_documents": rag_service.get_indexed_documents(), | |
| }, | |
| ) | |
| except HTTPException: | |
| raise | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| logger.exception("Scrape failed for %s", request.url) | |
| raise HTTPException(status_code=500, detail=f"Error scraping URL: {e}") | |
| async def delete_document(document_name: str): | |
| try: | |
| rag_service.delete_document(document_name) | |
| file_path = settings.upload_dir / document_name | |
| if file_path.exists() and file_path.is_file(): | |
| file_path.unlink(missing_ok=True) | |
| return DeleteDocumentResponse( | |
| status="success", | |
| message=f"Document '{document_name}' removed", | |
| deleted=document_name, | |
| indexed_documents=rag_service.get_indexed_documents(), | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=404, detail=str(e)) | |
| except Exception as e: | |
| logger.exception("Delete failed for %s", document_name) | |
| raise HTTPException(status_code=500, detail=f"Error deleting document: {e}") | |
| def _resolve_session(session_id: str | None) -> str: | |
| return session_id or str(uuid.uuid4()) | |
| def _rate_limit_detail(exc: Exception) -> str | None: | |
| """Return a readable message if this is a provider rate-limit error.""" | |
| text = str(exc) | |
| if "rate_limit_exceeded" not in text and "429" not in text: | |
| return None | |
| wait = re.search(r"try again in ([\d.]+)s", text) | |
| when = f" Try again in about {float(wait.group(1)):.0f}s." if wait else "" | |
| return ( | |
| "Groq rate limit reached (free tier allows 8,000 tokens/minute)." | |
| f"{when} Shorten the request, index fewer documents, or upgrade your Groq plan." | |
| ) | |
| async def stream_query(request: QueryRequest): | |
| if not rag_service.has_documents(): | |
| raise HTTPException(status_code=400, detail="No documents indexed. Please upload a document first.") | |
| session_id = _resolve_session(request.session_id) | |
| async def event_generator() -> AsyncGenerator[str, None]: | |
| try: | |
| yield f"data: {json.dumps({'session_id': session_id})}\n\n" | |
| try: | |
| await asyncio.to_thread(history_service.add_message, session_id, "user", request.question) | |
| except Exception: | |
| logger.exception("Failed to persist user message for session %s", session_id) | |
| answer_parts: list[str] = [] | |
| async for token in rag_service.stream_query(request.question, session_id): | |
| answer_parts.append(token) | |
| yield f"data: {json.dumps({'token': token})}\n\n" | |
| full_answer = "".join(answer_parts) | |
| sources = await rag_service.retrieve_sources(request.question) | |
| final = FinalResponse( | |
| final_answer=full_answer, | |
| sources=[s.model_dump() for s in sources], | |
| ) | |
| yield "data: [DONE]\n\n" | |
| yield f"data: {json.dumps(final.model_dump())}\n\n" | |
| try: | |
| await asyncio.to_thread( | |
| history_service.add_message, | |
| session_id, | |
| "assistant", | |
| full_answer, | |
| [s.model_dump() for s in sources], | |
| ) | |
| await asyncio.to_thread(history_service.touch_session, session_id) | |
| except Exception: | |
| logger.exception("Failed to persist assistant message for session %s", session_id) | |
| except Exception as e: | |
| logger.exception("Stream query failed") | |
| message = _rate_limit_detail(e) or str(e) | |
| yield f"data: {json.dumps({'error': message})}\n\n" | |
| return StreamingResponse( | |
| event_generator(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| "X-Accel-Buffering": "no", | |
| }, | |
| ) | |
| async def query(request: QueryRequest): | |
| if not rag_service.has_documents(): | |
| raise HTTPException(status_code=400, detail="No documents indexed. Please upload a document first.") | |
| try: | |
| answer, sources = await rag_service.query(request.question) | |
| return FinalResponse(final_answer=answer, sources=sources) | |
| except Exception as e: | |
| logger.exception("Query failed") | |
| raise HTTPException(status_code=500, detail=f"Error processing query: {e}") | |
| async def summarize(request: SummarizeRequest): | |
| if not rag_service.has_documents(): | |
| raise HTTPException(status_code=400, detail="No documents indexed. Please upload a document first.") | |
| try: | |
| summary = await rag_service.summarize(max_length=request.max_length) | |
| return SummaryResponse( | |
| summary=summary, | |
| word_count=len(summary.split()), | |
| source_documents=rag_service.get_indexed_documents(), | |
| ) | |
| except Exception as e: | |
| logger.exception("Summarize failed") | |
| detail = _rate_limit_detail(e) | |
| if detail: | |
| raise HTTPException(status_code=429, detail=detail) | |
| raise HTTPException(status_code=500, detail=f"Error generating summary: {e}") | |
| async def reset_index(): | |
| try: | |
| rag_service.reset_all() | |
| if settings.upload_dir.exists(): | |
| for path in settings.upload_dir.glob("*"): | |
| if path.is_file(): | |
| path.unlink(missing_ok=True) | |
| return StatusResponse(status="success", message="Index reset. All documents removed.") | |
| except Exception as e: | |
| logger.exception("Reset failed") | |
| raise HTTPException(status_code=500, detail=f"Error resetting index: {e}") | |
| async def get_status(): | |
| docs = rag_service.get_indexed_documents() | |
| return StatusResponse( | |
| status="online", | |
| message="Studyson RAG API is running", | |
| details={ | |
| "model": settings.groq_model, | |
| "has_documents": rag_service.has_documents(), | |
| "indexed_documents": docs, | |
| "document_count": len(docs), | |
| }, | |
| ) | |
| async def list_sessions(): | |
| return await asyncio.to_thread(history_service.list_sessions) | |
| async def get_session_messages(session_id: str): | |
| messages = await asyncio.to_thread(history_service.get_messages, session_id) | |
| return SessionMessagesResponse( | |
| session_id=session_id, | |
| messages=[ChatMessage(**m) for m in messages], | |
| ) | |
| async def rename_session(session_id: str, request: RenameSessionRequest): | |
| await asyncio.to_thread(history_service.rename_session, session_id, request.title) | |
| return StatusResponse(status="success", message="Session renamed") | |
| async def delete_session(session_id: str): | |
| await asyncio.to_thread(history_service.delete_session, session_id) | |
| rag_service.reset_session(session_id) | |
| return StatusResponse(status="success", message="Session deleted") | |
| async def spa_fallback(full_path: str): | |
| candidate = Path("static") / full_path | |
| if full_path and candidate.is_file(): | |
| return FileResponse(candidate) | |
| return FileResponse("static/index.html") | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("app.main:app", host=settings.host, port=settings.port) | |