from __future__ import annotations import shutil import threading from datetime import timedelta from pathlib import Path import psutil import pymupdf from flask import ( Flask, jsonify, redirect, render_template, request, send_file, url_for, ) from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.utils import secure_filename import config from rag.auth import AtlasAuth from rag.extractors import DocumentExtractor, ExtractionOptions from rag.groq_service import GroqService from rag.ingestion import IngestionManager, quick_fingerprint from rag.models import EmbeddingService, RerankerService from rag.retrieval import RetrievalService from rag.state import StateDB from rag.store import VectorStore from rag.uploads import UploadError, UploadManager app = Flask(__name__) app.secret_key = config.FLASK_SECRET_KEY app.config.update( MAX_CONTENT_LENGTH=config.UPLOAD_CHUNK_BYTES + 1024 * 1024, # Nouveau nom pour éviter qu'une ancienne cookie SameSite=Lax interfère. SESSION_COOKIE_NAME="atlas_session_v2", SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=config.IS_HF_SPACE, # Dans l'onglet intégré Hugging Face, Atlas tourne dans une iframe # cross-site (huggingface.co -> *.hf.space). La cookie OAuth doit donc # être utilisable dans ce contexte. SESSION_COOKIE_SAMESITE="None" if config.IS_HF_SPACE else "Lax", SESSION_COOKIE_PARTITIONED=config.IS_HF_SPACE, PERMANENT_SESSION_LIFETIME=timedelta(hours=8), ) app.wsgi_app = ProxyFix( app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1, ) auth = AtlasAuth(app, config) manager_required = auth.require_roles("manager", "admin") admin_required = auth.require_roles("admin") state = StateDB(config.STATE_DB_PATH) embedder = EmbeddingService( config.EMBEDDING_MODEL, config.EMBEDDING_DIMENSION, config.EMBED_BATCH_SIZE, config.TORCH_NUM_THREADS, ) reranker = RerankerService( config.RERANKER_MODEL, config.RERANK_BATCH_SIZE, config.RERANK_ENABLED, config.TORCH_NUM_THREADS, ) store = VectorStore( config.LANCEDB_DIR, config.EMBEDDING_DIMENSION, config.INDEX_STATE_PATH, records_dir=config.CHUNK_RECORDS_DIR, legacy_db_dir=config.LEGACY_LANCEDB_DIR, ) def restore_catalog_from_persistent_data() -> None: """Rend immédiatement utilisables les PDF et LanceDB du Bucket. Le Bucket initial contient déjà les PDF et les vecteurs, mais pas forcément le fichier SQLite local. On recrée uniquement le catalogue; aucun embedding et aucune réindexation ne sont recalculés. """ summaries = store.document_summaries() if not summaries: return files_by_name = { path.name: path for path in config.DOCUMENTS_DIR.rglob("*") if path.is_file() and path.suffix.lower() in config.SUPPORTED_EXTENSIONS } restored = 0 removed_orphans: list[str] = [] cleanup_errors: list[str] = [] for summary in summaries: document_id = str(summary.get("document_id") or "") filename = str(summary.get("filename") or "") source = files_by_name.get(filename) if source is None: label = filename or document_id try: # Le PDF a été supprimé mais d'anciens fragments peuvent être # restés dans la LanceDB historique après l'erreur FUSE. Ils ne # doivent jamais revenir dans les réponses du RAG. store.delete_document(document_id) state.delete_document(document_id) removed_orphans.append(label) except Exception as exc: cleanup_errors.append( f"{label} ({type(exc).__name__}: {exc})" ) continue state.restore_completed_document( document_id=str(summary["document_id"]), fingerprint=quick_fingerprint(source), filename=filename, path=str(source.resolve()), extension=source.suffix.lower(), total_pages=int(summary.get("total_pages") or 0), chunks=int(summary.get("chunks") or 0), ) restored += 1 if restored: print( f"[Atlas] Catalogue restauré : {restored} document(s) depuis le Bucket.", flush=True, ) if removed_orphans: print( "[Atlas] Fragments orphelins supprimés : " + ", ".join(removed_orphans), flush=True, ) if cleanup_errors: print( "[Atlas] Échec du nettoyage de fragments orphelins : " + "; ".join(cleanup_errors), flush=True, ) restore_catalog_from_persistent_data() extractor = DocumentExtractor(ExtractionOptions( ocr_enabled=config.OCR_ENABLED, ocr_language=config.OCR_LANGUAGE, ocr_dpi=config.OCR_DPI, ocr_min_text_chars=config.OCR_MIN_TEXT_CHARS, tesseract_cmd=config.TESSERACT_CMD, extract_tables=config.EXTRACT_TABLES, )) ingestion = IngestionManager( state, store, embedder, extractor, config.PAGE_BATCH_SIZE, config.CHUNK_WORDS, config.CHUNK_OVERLAP_WORDS, config.SUPPORTED_EXTENSIONS, config.AUTO_OPTIMIZE, ) retrieval = RetrievalService( store, embedder, reranker, config.DENSE_CANDIDATES, config.TEXT_CANDIDATES, config.RRF_K, config.RERANK_POOL, config.FINAL_SOURCES, config.NEIGHBOR_PAGES, ) groq_service = GroqService(config.GROQ_API_KEY, config.GROQ_MODEL, config.MAX_CONTEXT_CHARS) index_lock = threading.Lock() uploads = UploadManager( config.STATE_DB_PATH, config.UPLOAD_TEMP_DIR, config.UPLOADS_DIR, config.SUPPORTED_EXTENSIONS, config.UPLOAD_CHUNK_BYTES, config.UPLOAD_RESERVE_BYTES, ) def machine_stats() -> dict: memory = psutil.virtual_memory() disk = shutil.disk_usage(config.DATA_DIR) return { "ram_total_gb": round(memory.total / 1024**3, 1), "ram_used_gb": round(memory.used / 1024**3, 1), "ram_percent": round(memory.percent, 1), "disk_free_gb": round(disk.free / 1024**3, 1), "disk_total_gb": round(disk.total / 1024**3, 1), } PUBLIC_ENDPOINTS = { "login", "oauth_callback", "logout", "api_health", "static", } UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} @app.before_request def enforce_authentication_and_csrf(): if request.endpoint in PUBLIC_ENDPOINTS: return None user = auth.current_user() if not user: if request.path.startswith("/api/"): return jsonify({"error": "Authentification requise."}), 401 return redirect(url_for("login", next=request.full_path)) if request.method in UNSAFE_METHODS and not auth.csrf_valid(): return jsonify({ "error": "Jeton de sécurité invalide. Recharge la page puis réessaie." }), 403 return None @app.get("/login") def login(): return auth.login_redirect(request.args.get("next")) @app.get("/auth/callback") def oauth_callback(): try: destination = auth.finish_login() return redirect(destination) except PermissionError as exc: return ( "

Accès refusé

" f"

{str(exc)}

" "

Demande à l'administrateur Atlas de vérifier ton compte.

", 403, ) except Exception as exc: app.logger.exception("Échec OAuth Hugging Face") return ( "

Connexion impossible

" "

La connexion Hugging Face a échoué. " "Recharge le Space et réessaie.

" f"

Détail : {type(exc).__name__}

", 500, ) @app.get("/logout") def logout(): auth.logout() return render_template("logged_out.html") @app.get("/") def home(): return render_template("index.html") @app.get("/api/me") def api_me(): user = auth.current_user() if not user: return jsonify({"error": "Authentification requise."}), 401 return jsonify({ "authenticated": True, "user": user, "csrfToken": auth.csrf_token(), }) @app.get("/api/config") def api_config(): return jsonify({ "groqConfigured": groq_service.configured, "groqModel": config.GROQ_MODEL, "embeddingModel": config.EMBEDDING_MODEL, "rerankerModel": config.RERANKER_MODEL, "rerankerEnabled": config.RERANK_ENABLED, "queryExpansion": config.QUERY_EXPANSION_ENABLED, "ocrEnabled": config.OCR_ENABLED, "supportedExtensions": sorted(config.SUPPORTED_EXTENSIONS), "uploadChunkBytes": config.UPLOAD_CHUNK_BYTES, "uploadChunkMB": config.UPLOAD_CHUNK_MB, }) @app.get("/api/models") def api_models(): try: return jsonify({"models": groq_service.list_models()}) except Exception as exc: return jsonify({"models": [config.GROQ_MODEL], "warning": str(exc)}) @app.get("/api/status") @manager_required def api_status(): return jsonify({ "stats": {**state.stats(), "vector_rows": store.count()}, "machine": machine_stats(), "ingestion": { "paused": ingestion.paused, "queueSize": ingestion.queue_size, "currentDocumentId": ingestion.current_document_id, }, "models": {"embeddingLoaded": embedder.loaded, "rerankerLoaded": reranker.loaded}, "documents": state.list_documents(), "uploads": uploads.list_recent(50), }) @app.get("/api/events") @manager_required def api_events(): return jsonify({"events": state.list_events(int(request.args.get("limit", 100)))}) @app.post("/api/uploads/start") @manager_required def api_upload_start(): payload = request.get_json(silent=True) or {} filename = secure_filename(str(payload.get("filename", ""))) try: session = uploads.start( filename=filename, total_size=int(payload.get("size", 0)), fingerprint=str(payload.get("fingerprint", "")), ) return jsonify({"upload": session, "chunkSize": config.UPLOAD_CHUNK_BYTES}) except (UploadError, ValueError, TypeError) as exc: return jsonify({"error": str(exc)}), 400 @app.get("/api/uploads/") @manager_required def api_upload_status(upload_id: str): session = uploads.get(upload_id) if not session: return jsonify({"error": "Session d’upload inconnue."}), 404 return jsonify({"upload": session, "chunkSize": config.UPLOAD_CHUNK_BYTES}) @app.post("/api/uploads//chunk") @manager_required def api_upload_chunk(upload_id: str): try: offset = int(request.headers.get("X-Upload-Offset", "-1")) block = request.get_data(cache=False, as_text=False) session = uploads.append(upload_id, offset, block) return jsonify({"upload": session}) except (UploadError, ValueError, TypeError) as exc: session = uploads.get(upload_id) status = 409 if session else 404 return jsonify({"error": str(exc), "upload": session}), status @app.post("/api/uploads//complete") @manager_required def api_upload_complete(upload_id: str): try: session, path = uploads.complete(upload_id) documents = ingestion.enqueue_paths([path]) return jsonify({ "upload": session, "documents": documents, "message": "Upload terminé. Le document a été ajouté à la file d’indexation.", }) except UploadError as exc: return jsonify({"error": str(exc), "upload": uploads.get(upload_id)}), 409 @app.delete("/api/uploads/") @manager_required def api_upload_cancel(upload_id: str): try: session = uploads.cancel(upload_id) return jsonify({"upload": session, "message": "Upload annulé et fichier temporaire supprimé."}) except UploadError as exc: return jsonify({"error": str(exc)}), 409 @app.post("/api/ingest-folder") @admin_required def api_ingest_folder(): paths = [ path for path in config.DOCUMENTS_DIR.rglob("*") if path.is_file() and path.suffix.lower() in config.SUPPORTED_EXTENSIONS ] return jsonify({"found": len(paths), "documents": ingestion.enqueue_paths(paths)}) @app.post("/api/ingestion/pause") @admin_required def api_pause(): ingestion.pause() return jsonify({"message": "Indexation en pause après le lot en cours."}) @app.post("/api/ingestion/resume") @admin_required def api_resume(): ingestion.resume() return jsonify({"message": "Indexation reprise."}) @app.post("/api/documents//cancel") @manager_required def api_cancel(document_id: str): ingestion.cancel(document_id) return jsonify({"message": "Annulation demandée."}) @app.post("/api/documents//retry") @manager_required def api_retry(document_id: str): if not ingestion.retry(document_id): return jsonify({"error": "Document introuvable ou déjà dans la file."}), 404 return jsonify({"message": "Document remis dans la file."}) @app.post("/api/documents//reindex") @manager_required def api_reindex(document_id: str): doc = state.get_document(document_id) if not doc: return jsonify({"error": "Document inconnu."}), 404 if ingestion.is_active(document_id): return jsonify({"error": "Ce document est encore actif. Annule-le et attends l’état Annulé avant de le réindexer."}), 409 documents = ingestion.enqueue_paths([Path(doc["path"])], force=True) return jsonify({"message": "Réindexation lancée.", "documents": documents}) def _managed_document_path(raw_path: str) -> Path: """Retourne un chemin sûr appartenant au stockage géré par Atlas.""" path = Path(raw_path).expanduser().resolve() allowed_roots = { Path(config.DOCUMENTS_DIR).expanduser().resolve(), Path(config.UPLOADS_DIR).expanduser().resolve(), } if not any(path == root or path.is_relative_to(root) for root in allowed_roots): raise ValueError( "Le fichier est en dehors des dossiers gérés par Atlas; " "suppression physique refusée." ) return path @app.delete("/api/documents/") @manager_required def api_delete(document_id: str): if ingestion.is_active(document_id): return jsonify({ "error": ( "Ce document est encore dans la file ou en cours. " "Annule-le d’abord, puis attends l’état Annulé." ) }), 409 doc = state.get_document(document_id) if not doc: return jsonify({"error": "Document inconnu."}), 404 try: source_path = _managed_document_path(str(doc["path"])) except (KeyError, TypeError, ValueError) as exc: return jsonify({"error": str(exc)}), 400 # On nettoie d'abord la base active locale et l'archive persistante. # Ainsi, si la suppression du PDF échoue, l'utilisateur peut simplement # relancer l'action sans retrouver des fragments fantômes. try: store.delete_document(document_id) except Exception as exc: return jsonify({ "error": ( "Impossible de nettoyer les fragments du document : " f"{type(exc).__name__}: {exc}" ) }), 500 try: if source_path.exists(): if not source_path.is_file(): return jsonify({ "error": "Le chemin du document n’est pas un fichier." }), 409 source_path.unlink() except OSError as exc: return jsonify({ "error": ( "Les fragments ont été supprimés, mais le PDF n’a pas pu " f"être effacé. Relance Supprimer : {exc}" ) }), 500 try: state.delete_document(document_id) except Exception as exc: return jsonify({ "error": ( "Le fichier et les fragments ont été supprimés, mais " "l’entrée du catalogue n’a pas pu être nettoyée. " f"Relance Supprimer : {type(exc).__name__}: {exc}" ) }), 500 return jsonify({ "message": ( "Document supprimé définitivement : fichier, fragments persistants " "et état d’indexation effacés." ) }) @app.get("/api/documents//file") def api_document_file(document_id: str): doc = state.get_document(document_id) if not doc or not Path(doc["path"]).is_file(): return jsonify({"error": "Fichier introuvable."}), 404 return send_file(doc["path"], as_attachment=False, download_name=doc["filename"]) @app.get("/api/documents//page//preview") def api_page_preview(document_id: str, page_number: int): doc = state.get_document(document_id) if not doc or Path(doc["path"]).suffix.lower() != ".pdf": return jsonify({"error": "Aperçu disponible uniquement pour les PDF."}), 404 path = Path(doc["path"]) if not path.is_file(): return jsonify({"error": "Fichier introuvable."}), 404 with pymupdf.open(path) as pdf: if page_number < 1 or page_number > pdf.page_count: return jsonify({"error": "Page invalide."}), 404 page = pdf.load_page(page_number - 1) pix = page.get_pixmap(matrix=pymupdf.Matrix(1.4, 1.4), alpha=False) return app.response_class(pix.tobytes("png"), mimetype="image/png") def run_retrieval(question: str, payload: dict): expansion = bool(payload.get("queryExpansion", config.QUERY_EXPANSION_ENABLED)) model = str(payload.get("model", "")).strip() or None variants = groq_service.expand_queries(question, enabled=expansion, model=model) return retrieval.search(question, variants) @app.post("/api/search") def api_search(): payload = request.get_json(silent=True) or {} question = str(payload.get("query", "")).strip() if not question: return jsonify({"error": "Écris une question."}), 400 if store.count() == 0: return jsonify({"error": "La base est vide. Indexe d’abord un document."}), 400 try: result = run_retrieval(question, payload) return jsonify({"query": question, **result}) except Exception as exc: return jsonify({"error": f"Recherche impossible : {type(exc).__name__}: {exc}"}), 500 @app.post("/api/ask") def api_ask(): payload = request.get_json(silent=True) or {} question = str(payload.get("question", "")).strip() model = str(payload.get("model", "")).strip() or None if not question: return jsonify({"error": "Écris une question."}), 400 if store.count() == 0: return jsonify({"error": "La base est vide. Indexe d’abord un document."}), 400 try: result = run_retrieval(question, payload) answer = groq_service.answer(question, result["sources"], model=model) return jsonify({"question": question, "answer": answer, **result}) except Exception as exc: return jsonify({"error": f"Réponse impossible : {type(exc).__name__}: {exc}"}), 500 @app.post("/api/indexes/optimize") @admin_required def api_optimize(): if not index_lock.acquire(blocking=False): return jsonify({"error": "Une optimisation est déjà en cours."}), 409 def worker(): try: result = store.build_indexes(full_rebuild=True) state.add_event(result.get("message", "Index optimisés.")) except Exception as exc: state.add_event(f"Échec optimisation : {exc}", level="error") finally: index_lock.release() threading.Thread(target=worker, daemon=True, name="atlas-index-builder").start() return jsonify({"message": "Optimisation complète lancée en arrière-plan."}) @app.post("/api/models/unload") @admin_required def api_unload_models(): embedder.unload() reranker.unload() return jsonify({"message": "Modèles libérés de la RAM. Ils seront rechargés au prochain besoin."}) @app.get("/api/health") def api_health(): return jsonify({"ok": True}) @app.errorhandler(413) def too_large(_): return jsonify({ "error": f"Bloc trop volumineux. L’application envoie les fichiers par blocs de {config.UPLOAD_CHUNK_MB} Mo." }), 413 if __name__ == "__main__": app.run(host=config.HOST, port=config.PORT, debug=False, threaded=True)