aim / app /main.py
Claude
Make companion respond in the selected language (EN/FR)
60bcd69 unverified
"""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__})