SPB Git

spb/uqo-chat

Public
6.8 KB · 163 lines python
1"""Professor dashboard: analytics, content ingestion, agent settings, announcements."""23from __future__ import annotations45import asyncio6import tempfile7from pathlib import Path8from typing import Any910from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status11from pydantic import BaseModel12from sqlalchemy import delete, select1314from app.core.config import Settings, get_settings15from app.core.security import AuthUser, require_professor16from app.db import SessionLocal17from app.models import CourseChunk, CourseDocument18from app.rag import retriever19from app.rag.ingest import ingest_file20from app.services import analytics, costs, users21from app.services import courses as course_service22from app.tools.all import registry2324router = APIRouter(prefix="/professor", tags=["professor"])2526INGEST_JOBS: dict[str, dict[str, Any]] = {}272829@router.get("/dashboard")30async def dashboard(days: int = 30, _: AuthUser = Depends(require_professor)) -> dict:31    data = await analytics.dashboard(days)32    data["budget"] = await costs.budget_status()33    data["costs_by_course"] = await costs.costs_by_course()34    return data353637@router.get("/settings")38async def get_settings_(_: AuthUser = Depends(require_professor),39                        settings: Settings = Depends(get_settings)) -> dict:40    courses = await course_service.list_courses(include_private=True)41    return {"courses": courses, "tools": registry.names(),42            "models": {"primary": settings.MODEL_TUTOR_PRIMARY,43                       "fallback": settings.MODEL_TUTOR_FALLBACK,44                       "reasoning": settings.MODEL_REASONING, "fast": settings.MODEL_FAST},45            "budget_usd": settings.LLM_MONTHLY_BUDGET_USD}464748class CourseSettingsReq(BaseModel):49    extra_system_prompt: str | None = None50    announcement: str | None = None51    deadlines: list[dict[str, Any]] | None = None52    suggestions: list[str] | None = None53    settings: dict[str, Any] | None = None545556@router.patch("/settings/{course}")57async def patch_settings(course: str, req: CourseSettingsReq,58                         _: AuthUser = Depends(require_professor)) -> dict:59    data = await course_service.update_course(course, req.model_dump(exclude_none=True))60    if not data:61        raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Cours introuvable.")62    return data636465@router.get("/content")66async def list_content(_: AuthUser = Depends(require_professor)) -> dict:67    async with SessionLocal() as session:68        rows = (await session.execute(select(CourseDocument)69                                      .order_by(CourseDocument.course_code,70                                                CourseDocument.filename))).scalars()71        docs = [{"id": d.id, "course": d.course_code, "filename": d.filename, "title": d.title,72                 "visibility": d.visibility, "n_chunks": d.n_chunks,73                 "ingested_at": d.ingested_at.isoformat()} for d in rows]74    return {"documents": docs, "index_size": len(retriever.index.docs), "jobs": INGEST_JOBS}757677async def _ingest_job(job_id: str, course: str, path: Path, visibility: str, title: str) -> None:78    INGEST_JOBS[job_id]["status"] = "running"79    try:80        n = await ingest_file(course, path, visibility, module=title or None)81        await retriever.rebuild_index()82        INGEST_JOBS[job_id].update({"status": "done", "chunks": n})83    except Exception as exc:  # noqa: BLE00184        INGEST_JOBS[job_id].update({"status": "error", "error": str(exc)[:300]})85    finally:86        path.unlink(missing_ok=True)878889@router.post("/content", status_code=202)90async def upload_content(file: UploadFile = File(...), course: str = Form(...),91                         visibility: str = Form("students"), title: str = Form(""),92                         _: AuthUser = Depends(require_professor),93                         settings: Settings = Depends(get_settings)) -> dict:94    course = course.upper()95    if course not in settings.courses:96        raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Cours inconnu.")97    if visibility not in {"students", "professor_only"}:98        raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Visibilité invalide.")99    suffix = Path(file.filename or "doc").suffix.lower()100    if suffix not in {".pdf", ".docx", ".pptx", ".md", ".txt", ".tex", ".html"}:101        raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Format non pris en charge.")102    data = await file.read()103    tmp_dir = settings.DATA_DIR / "ingest"104    tmp_dir.mkdir(exist_ok=True)105    tmp = Path(tempfile.mkstemp(suffix=suffix, prefix="up_", dir=tmp_dir)[1])106    tmp.write_bytes(data)107    # keep original filename for the document record108    target = tmp_dir / f"{tmp.stem}__{Path(file.filename or 'doc').name}"109    tmp.rename(target)110    job_id = target.stem[:12]111    INGEST_JOBS[job_id] = {"status": "queued", "filename": file.filename, "course": course}112    asyncio.create_task(_ingest_job(job_id, course, target, visibility, title))113    return {"job_id": job_id}114115116class VisibilityReq(BaseModel):117    visibility: str118119120@router.patch("/content/{doc_id}")121async def set_visibility(doc_id: str, req: VisibilityReq,122                         _: AuthUser = Depends(require_professor)) -> dict:123    async with SessionLocal() as session:124        doc = await session.get(CourseDocument, doc_id)125        if not doc:126            raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")127        doc.visibility = req.visibility128        rows = (await session.execute(select(CourseChunk)129                                      .where(CourseChunk.document_id == doc_id))).scalars()130        for c in rows:131            c.visibility = req.visibility132        await session.commit()133    await retriever.rebuild_index()134    return {"ok": True}135136137@router.delete("/content/{doc_id}")138async def delete_doc(doc_id: str, _: AuthUser = Depends(require_professor)) -> dict:139    async with SessionLocal() as session:140        doc = await session.get(CourseDocument, doc_id)141        if not doc:142            raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")143        await session.execute(delete(CourseChunk).where(CourseChunk.document_id == doc_id))144        await session.delete(doc)145        await session.commit()146    await retriever.rebuild_index()147    return {"ok": True}148149150class PromoteReq(BaseModel):151    email: str152    role: str = "professor"153154155@router.post("/promote")156async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) -> dict:157    if req.role not in {"student", "professor"}:158        raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")159    if not await users.set_role(req.email, req.role):160        raise HTTPException(status.HTTP_404_NOT_FOUND,161                            detail="Compte introuvable (la personne doit s'être connectée une fois).")162    return {"ok": True}163
Files · afb5ca448565f356efc1532400a8ac8b8eba5d6a
Menu

Theme