pdf-quiz-generator/backend/app/routers/documents.py
Daniel 47ba213ae3 Major platform update: pgvector search, multi-provider TTS, settings page, CLI
Features:
- Hybrid semantic + keyword quiz search (pgvector HNSW + PostgreSQL ILIKE)
- AWS Bedrock Titan Embed V2 embeddings via LiteLLM proxy (0.71 cosine sim)
- Multi-provider TTS: OpenAI, AWS Polly (neural), ElevenLabs, Google Cloud TTS
- Unified Settings page (profile, theme, Nextcloud integration, admin shortcuts)
- Good morning/afternoon greeting on dashboard
- manage.py CLI: reset-password, list-users, reembed
- Email verification enforced: register no longer returns JWT for unverified users
- Quiz search with debounced input, semantic/keyword/title modes, highlighted snippets
- TTS button: loading/playing states, voice selector locked during playback
- TTS auto-stops when navigating between questions
- Footer added; mobile quiz nav overflow fixed; markdown theme body selector fixed
- OpenAI Alloy as default TTS voice; favicon added
- SMTP configured via smtp2go; password reset rate limiting (3/hour)
- PostgreSQL upgraded to pgvector/pgvector:pg16

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:03:10 +02:00

184 lines
6 KiB
Python

import os
import uuid
import shutil
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.pdf_document import PDFDocument
from app.models.section import Section
from app.models.user import User
from app.schemas.document import DocumentResponse, DocumentStatusResponse, SectionCreate, SectionResponse
from app.utils.auth import get_current_user, require_moderator
from app.services import vector_service
router = APIRouter()
@router.post("/upload", response_model=DocumentResponse)
def upload_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
if not file.filename or not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are accepted")
# Save file to disk streaming (handles large files)
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{os.path.basename(file.filename)}"
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer, length=1024 * 1024)
# Check file size
file_size = os.path.getsize(file_path)
if file_size > settings.MAX_UPLOAD_SIZE:
os.remove(file_path)
raise HTTPException(status_code=400, detail=f"File too large. Max size: {settings.MAX_UPLOAD_SIZE} bytes")
# Create DB record
doc = PDFDocument(
user_id=current_user.id,
filename=safe_name,
original_filename=file.filename,
status="processing",
)
db.add(doc)
db.commit()
db.refresh(doc)
# Dispatch background processing
try:
from app.tasks.pdf_tasks import process_pdf
process_pdf.delay(doc.id, file_path)
except Exception:
# If Celery/Redis not available, process synchronously
from app.services import pdf_service
try:
total_pages = pdf_service.get_page_count(file_path)
doc.total_pages = total_pages
pages = pdf_service.extract_text_by_page(file_path)
if pages:
vector_service.store_pages(doc.id, pages)
doc.status = "ready"
else:
doc.status = "error"
doc.error_message = "No text could be extracted"
db.commit()
except Exception as e:
doc.status = "error"
doc.error_message = str(e)[:500]
db.commit()
db.refresh(doc)
return doc
@router.get("/", response_model=list[DocumentResponse])
def list_documents(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.is_moderator:
docs = db.query(PDFDocument).order_by(PDFDocument.uploaded_at.desc()).all()
else:
docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).order_by(PDFDocument.uploaded_at.desc()).all()
return docs
@router.get("/{document_id}", response_model=DocumentResponse)
def get_document(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
@router.get("/{document_id}/status", response_model=DocumentStatusResponse)
def get_document_status(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentStatusResponse(
id=doc.id,
status=doc.status,
total_pages=doc.total_pages,
error_message=doc.error_message,
)
@router.post("/{document_id}/sections", response_model=SectionResponse)
def create_section(
document_id: int,
section_data: SectionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if doc.status != "ready":
raise HTTPException(status_code=400, detail="Document is not ready yet")
if section_data.start_page < 1 or section_data.end_page < 1:
raise HTTPException(status_code=400, detail="Page numbers must be positive")
if section_data.start_page >= section_data.end_page:
raise HTTPException(status_code=400, detail="start_page must be less than end_page")
if doc.total_pages and section_data.end_page > doc.total_pages:
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")
section = Section(
document_id=document_id,
name=section_data.name,
start_page=section_data.start_page,
end_page=section_data.end_page,
)
db.add(section)
db.commit()
db.refresh(section)
return section
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
# Delete file
file_path = os.path.join(settings.UPLOAD_DIR, doc.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete ChromaDB collection
vector_service.delete_collection(document_id)
db.delete(doc)
db.commit()