File size: 6,912 Bytes
87344df 1451885 4a021be 1e29076 1451885 87344df 6e796e2 87344df 6e796e2 1451885 87344df 1451885 6e796e2 87344df 4a021be 87344df 6e796e2 87344df 4a021be 60bcd69 87344df 3092594 87344df 1e29076 87344df 4a021be 87344df 4a021be 1451885 4a021be 1451885 60bcd69 87344df 1451885 87344df 1451885 4a021be 87344df 4a021be 1451885 87344df 6e796e2 4a021be 6e796e2 4a021be 6e796e2 87344df 1451885 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """FastAPI application for AIM Learning Companion."""
import logging
import os
import re
import traceback
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List
from fastapi import FastAPI, UploadFile, File
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
from app.rag import load_corpus, retrieve, add_documents, list_documents, delete_document
from app.llm import build_system_prompt, chat, analyze_session
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load corpus on startup."""
load_corpus()
yield
app = FastAPI(title="AIM Learning Companion", lifespan=lifespan)
STATIC_DIR = Path(__file__).parent.parent / "static"
CORPUS_DIR = Path(__file__).parent.parent / "corpus"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
ALLOWED_EXTENSIONS = {".txt", ".pdf", ".pptx", ".ppt", ".zip"}
class ChatRequest(BaseModel):
message: str
mode: str = "TUTOR"
topic: str = ""
phase: int = 0
phase_turns: int = 0
lang: str = "en"
history: list[dict] = []
class ChatResponse(BaseModel):
reply: str
phase: int
phase_turns: int = 0
class AnalysisRequest(BaseModel):
history: list[dict] = []
timestamps: list[float] = []
class AnalysisResponse(BaseModel):
reasoningScore: int = 0
clarityScore: int = 0
skepticismScore: int = 0
processScore: int = 0
reflectionScore: int = 0
integrityScore: int = 0
summary: str = ""
keyStrengths: list[str] = []
weaknesses: list[str] = []
rhythmBreakCount: int = 0
@app.get("/")
async def index():
return FileResponse(str(STATIC_DIR / "index.html"))
@app.get("/download/{filename}")
async def download_file(filename: str):
"""Serve a file from the corpus directory for download."""
file_path = CORPUS_DIR / filename
if not file_path.exists() or not file_path.is_file():
return JSONResponse(status_code=404, content={"error": "Fichier non trouvé"})
return FileResponse(str(file_path), filename=filename)
MAX_TURNS_PER_PHASE = 2
def _compute_phase(current_phase: int, phase_turns: int) -> tuple[int, int]:
"""Advance phase based on conversation depth.
Returns (new_phase, new_phase_turns).
Phase advances after MAX_TURNS_PER_PHASE learner turns in the same phase.
"""
new_turns = phase_turns + 1
if new_turns >= MAX_TURNS_PER_PHASE and current_phase < 4:
return current_phase + 1, 0
return current_phase, new_turns
@app.post("/api/chat", response_model=ChatResponse)
async def api_chat(req: ChatRequest):
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
base_url = os.environ.get("LLM_BASE_URL", "").strip()
model = os.environ.get("LLM_MODEL", "").strip()
if not api_key:
logger.error("OPENROUTER_API_KEY is not set!")
return JSONResponse(status_code=500, content={"error": "Cle API non configuree (OPENROUTER_API_KEY manquant)"})
try:
# Compute phase progression server-side
new_phase, new_phase_turns = _compute_phase(req.phase, req.phase_turns)
logger.info(f"Chat request: mode={req.mode}, topic={req.topic[:50]}, phase={req.phase}->{new_phase}, turns={req.phase_turns}->{new_phase_turns}, model={model}")
rag_chunks = retrieve(req.message)
system_prompt = build_system_prompt(req.mode, req.topic, new_phase, rag_chunks, req.lang)
messages = [{"role": m["role"], "content": m["content"]} for m in req.history]
messages.append({"role": "user", "content": req.message})
reply = await chat(system_prompt, messages)
logger.info(f"LLM reply received ({len(reply)} chars)")
return ChatResponse(reply=reply, phase=new_phase, phase_turns=new_phase_turns)
except Exception as e:
logger.error(f"Chat error: {e}\n{traceback.format_exc()}")
return JSONResponse(status_code=500, content={"error": str(e)})
@app.post("/api/upload")
async def api_upload(files: List[UploadFile] = File(...)):
"""Upload one or more files (PDF, PPTX, TXT, ZIP) to the RAG corpus."""
file_data = []
skipped = []
for f in files:
ext = Path(f.filename).suffix.lower() if f.filename else ""
if ext not in ALLOWED_EXTENSIONS:
skipped.append({"filename": f.filename, "reason": f"Type non supporté: {ext}"})
continue
content = await f.read()
file_data.append((f.filename, content))
results = add_documents(file_data) if file_data else []
return {"results": results, "skipped": skipped}
@app.get("/api/documents")
async def api_documents():
"""List all documents in the corpus."""
return {"documents": list_documents()}
@app.delete("/api/documents/{filename}")
async def api_delete_document(filename: str):
"""Delete a document from the corpus."""
ok = delete_document(filename)
if ok:
return {"status": "ok"}
return {"status": "error", "message": "Fichier non trouvé"}
@app.post("/api/analyze", response_model=AnalysisResponse)
async def api_analyze(req: AnalysisRequest):
analysis = await analyze_session(req.history)
rhythm_breaks = 0
if len(req.timestamps) >= 2:
for i in range(1, len(req.timestamps), 2):
if i + 1 < len(req.timestamps):
gap = req.timestamps[i + 1] - req.timestamps[i]
if 0 < gap < 8:
rhythm_breaks += 1
return AnalysisResponse(
reasoningScore=analysis.get("reasoningScore", 0),
clarityScore=analysis.get("clarityScore", 0),
skepticismScore=analysis.get("skepticismScore", 0),
processScore=analysis.get("processScore", 0),
reflectionScore=analysis.get("reflectionScore", 0),
integrityScore=analysis.get("integrityScore", 0),
summary=analysis.get("summary", ""),
keyStrengths=analysis.get("keyStrengths", []),
weaknesses=analysis.get("weaknesses", []),
rhythmBreakCount=rhythm_breaks,
)
@app.get("/api/health")
async def health():
return {
"status": "ok",
"has_api_key": bool(os.environ.get("OPENROUTER_API_KEY", "")),
"base_url": os.environ.get("LLM_BASE_URL", "(not set)"),
"model": os.environ.get("LLM_MODEL", "(not set)"),
}
@app.get("/api/test-llm")
async def test_llm():
"""Quick test of the LLM connection."""
try:
reply = await chat("Tu es un assistant. Reponds en une phrase.", [{"role": "user", "content": "Dis bonjour."}])
return {"status": "ok", "reply": reply}
except Exception as e:
logger.error(f"LLM test error: {e}\n{traceback.format_exc()}")
return JSONResponse(status_code=500, content={"error": str(e), "type": type(e).__name__})
|