YLF-AI-backup / app.py
mohamedalaa-505
initilize deployment
6dbbc6f
Raw
History Blame Contribute Delete
7.37 kB
import re
import uuid
import asyncio
import logging
import tempfile
import os
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
from src.chatbot.engine import call_llm
from src.chatbot.prompts import AI_MODES, DEFAULT_PROMPT
from src.chatbot.document_hub import process_document, list_documents, delete_document
from src.chatbot.rag_pipeline import index_document, retrieve_context, build_rag_prompt
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ylf-api")
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(
title="YLF AI Platform",
description="AI Tutoring API — RAG, multiple explanation modes, exam generation.",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
VALID_MODES = list(AI_MODES.keys())
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@app.get("/")
def home():
return {"message": "YLF API is running 🚀"}
# ---------------------------------------------------------------------------
# Document endpoints
# ---------------------------------------------------------------------------
@app.post("/upload")
async def upload_pdf(file: UploadFile = File(...)):
"""
Upload a PDF → extract → chunk (with page metadata) → index for RAG.
Returns doc_id to scope /chat requests to this document.
"""
if not file.filename.endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are supported.")
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
tmp.write(await file.read())
tmp_path = tmp.name
try:
result = process_document(tmp_path, filename=file.filename)
if result["status"] == "error":
raise HTTPException(status_code=422, detail=result["message"])
index_result = index_document(result["doc_id"])
logger.info(
f"[Upload] {file.filename} → doc_id={result['doc_id']} "
f"| {index_result['chunks_indexed']} chunks indexed"
)
return {
"doc_id": result["doc_id"],
"filename": result["filename"],
"num_chunks": result["num_chunks"],
}
finally:
os.unlink(tmp_path)
@app.get("/documents")
def get_documents():
"""List all currently indexed documents."""
return {"documents": list_documents()}
@app.delete("/documents/{doc_id}")
def remove_document(doc_id: str):
"""Remove a document from the index."""
removed = delete_document(doc_id)
if not removed:
raise HTTPException(status_code=404, detail=f"Document '{doc_id}' not found.")
return {"status": "deleted", "doc_id": doc_id}
# ---------------------------------------------------------------------------
# Chat endpoint
# ---------------------------------------------------------------------------
class ChatRequest(BaseModel):
message: str
mode: str = "socratic"
session_id: Optional[str] = None
doc_id: Optional[str] = None # restrict RAG to one document
@app.post("/chat")
async def chat_endpoint(request: ChatRequest):
"""
Main chat endpoint with smart routing, fallback, and RAG support.
Supported modes: socratic, explain_beginner, explain_intermediate,
explain_expert, question_gen, chunking_helper
Response shape (Day 5 spec):
{
"answer": str,
"questions": list[str], # populated when mode == "question_gen"
"source": str | null, # e.g. "page 4" from RAG retrieval
"session_id": str,
"mode": str,
"status": str # "success" | "temporary_failure"
}
"""
mode = request.mode.lower().strip()
if mode not in VALID_MODES:
raise HTTPException(
status_code=400,
detail=f"Invalid mode '{mode}'. Valid modes: {VALID_MODES}"
)
# Consistent session ID format with engine logs
session_id = request.session_id or f"user_{uuid.uuid4().hex[:8]}"
logger.info(f"Session: {session_id} | Mode: {mode} | Msg: {request.message[:80]}")
# ------------------------------------------------------------------
# RAG: retrieve context if any documents are indexed
# ------------------------------------------------------------------
source = None
rag_context_str = None
if list_documents():
rag_result = retrieve_context(request.message, doc_id=request.doc_id)
source = rag_result["source"]
rag_context_str = rag_result["context_str"]
# ------------------------------------------------------------------
# Build system prompt — inject RAG context if available
# ------------------------------------------------------------------
base_prompt = AI_MODES.get(mode, DEFAULT_PROMPT)
if rag_context_str and "No relevant context" not in rag_context_str:
augmented_prompt = build_rag_prompt(request.message, rag_context_str, base_prompt)
_rag_key = f"__rag_{session_id}"
AI_MODES[_rag_key] = augmented_prompt
try:
# Use to_thread — engine performs blocking HTTP requests
answer = await asyncio.to_thread(
call_llm,
user_query=request.message,
mode=_rag_key,
session_id=session_id
)
finally:
AI_MODES.pop(_rag_key, None) # always clean up
else:
answer = await asyncio.to_thread(
call_llm,
user_query=request.message,
mode=mode,
session_id=session_id
)
# ------------------------------------------------------------------
# Handle total engine failure (all models + retries exhausted)
# ------------------------------------------------------------------
if "All models failed" in answer:
logger.error(f"Critical Engine Failure: {answer}")
return {
"answer": "Sorry, our AI engines are currently under heavy load. Please try again in a minute.",
"questions": [],
"source": source,
"session_id": session_id,
"mode": mode,
"status": "temporary_failure"
}
# ------------------------------------------------------------------
# Parse questions list when mode is question_gen
# ------------------------------------------------------------------
questions = []
if mode == "question_gen":
questions = [
line.strip()
for line in answer.split("\n")
if re.match(r'^[\d\-\*\•]', line.strip()) and len(line.strip()) > 5
]
return {
"answer": answer,
"questions": questions,
"source": source,
"session_id": session_id,
"mode": mode,
"status": "success"
}