import json import logging import os import threading from datetime import datetime import requests from dotenv import load_dotenv from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from huggingface_hub import HfApi, create_repo, hf_hub_download from openai import OpenAI from pydantic import BaseModel, Field, field_validator from pypdf import PdfReader from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address from starlette.middleware.base import BaseHTTPMiddleware logger = logging.getLogger("assistant") load_dotenv(override=True) def _upload_to_hf(path_or_fileobj, path_in_repo, repo_id): """Upload en arrière-plan vers HF avec retry.""" try: api = HfApi() api.upload_file( path_or_fileobj=path_or_fileobj, path_in_repo=path_in_repo, repo_id=repo_id, repo_type="dataset", token=os.getenv("HF_TOKEN"), ) except Exception as e: print(f"[UPLOAD] Erreur upload HF ({path_in_repo}) : {e}", flush=True) # Chemins dans le dataset HF (pas exposés dans le repo public me/) KNOWLEDGE_HF_PATHS = ( "me/cv.pdf", "me/summary.txt", "me/Travaux.txt", ) KNOWLEDGE_LOCAL_PATHS = KNOWLEDGE_HF_PATHS def _knowledge_config(): """Retourne (token, dataset_id) ou (None, None).""" token = os.getenv("HF_TOKEN") dataset_id = os.getenv("KNOWLEDGE_DATASET_ID") if token and dataset_id: return token, dataset_id return None, None def _hf_knowledge_file_path(path_in_repo: str) -> str | None: """Chemin cache HF (hors dossier me/ du Space).""" token, dataset_id = _knowledge_config() if not token or not dataset_id: return None try: return hf_hub_download( repo_id=dataset_id, repo_type="dataset", filename=path_in_repo, token=token, ) except Exception: print(f"[KNOWLEDGE] {path_in_repo} absent sur HF", flush=True) return None def _read_text_file(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() def _read_pdf_text(path: str) -> str: text = "" reader = PdfReader(path) for page in reader.pages: page_text = page.extract_text() if page_text: text += page_text return text def remove_local_knowledge_copies(): """Supprime les copies sensibles dans me/ (runtime du conteneur).""" cwd = os.getcwd() removed: list[str] = [] absent: list[str] = [] errors: list[str] = [] for local_path in KNOWLEDGE_LOCAL_PATHS: full = os.path.join(cwd, local_path) if not os.path.isfile(local_path) and not os.path.isfile(full): absent.append(local_path) continue target = local_path if os.path.isfile(local_path) else full try: os.remove(target) removed.append(local_path) except OSError as exc: errors.append(f"{local_path} ({exc})") if removed: print( f"[KNOWLEDGE] Supprimé de me/ (runtime): {', '.join(removed)}", flush=True, ) if absent: print( "[KNOWLEDGE] Déjà absents de me/ au runtime " f"(chargement HF uniquement): {', '.join(absent)}", flush=True, ) if errors: print( f"[KNOWLEDGE] Échec suppression: {'; '.join(errors)}", flush=True, ) if not removed and not absent and not errors: print("[KNOWLEDGE] Aucun fichier knowledge à traiter.", flush=True) def init_knowledge_dataset(): """Crée le dataset privé et bootstrap depuis me/ si besoin (one-shot).""" token, dataset_id = _knowledge_config() if not token or not dataset_id: print( "[KNOWLEDGE] HF_TOKEN ou KNOWLEDGE_DATASET_ID manquant, " "dataset non initialisé.", flush=True, ) return try: create_repo( repo_id=dataset_id, repo_type="dataset", private=True, token=token, exist_ok=True, ) print(f"[KNOWLEDGE] Dataset prêt : {dataset_id}", flush=True) except Exception as e: print(f"[KNOWLEDGE] Erreur init dataset : {e}", flush=True) return os.makedirs("me", exist_ok=True) uploaded = 0 for path_in_repo in KNOWLEDGE_HF_PATHS: if _hf_knowledge_file_path(path_in_repo): continue if os.path.isfile(path_in_repo): _upload_to_hf(path_in_repo, path_in_repo, dataset_id) print( f"[KNOWLEDGE] {path_in_repo} envoyé vers HF (bootstrap)", flush=True, ) uploaded += 1 if uploaded: print( f"[KNOWLEDGE] {uploaded} fichier(s) bootstrap vers HF.", flush=True, ) remove_local_knowledge_copies() def init_logs_dataset(): token = os.getenv("HF_TOKEN") dataset_id = os.getenv("LOGS_DATASET_ID") if not token or not dataset_id: print("[LOG] HF_TOKEN ou LOGS_DATASET_ID manquant, dataset non initialisé.", flush=True) return try: create_repo(repo_id=dataset_id, repo_type="dataset", private=True, token=token, exist_ok=True) print(f"[LOG] Dataset logs prêt : {dataset_id}", flush=True) except Exception as e: print(f"[LOG] Erreur init dataset : {e}", flush=True) # Récupérer le fichier existant depuis HF pour ne pas perdre les données après un rebuild try: path = hf_hub_download(repo_id=dataset_id, repo_type="dataset", filename="me/log.txt", token=token) with open(path, "r", encoding="utf-8") as src: content = src.read() with open("me/log.txt", "w", encoding="utf-8") as dst: dst.write(content) print(f"[LOG] Fichier log.txt récupéré ({len(content.splitlines())} lignes)", flush=True) except Exception: print("[LOG] Aucun fichier log.txt existant sur HF, démarrage à vide.", flush=True) def init_questions_dataset(): token = os.getenv("HF_TOKEN") questions_id = os.getenv("QUESTIONS_DATASET_ID") if not token or not questions_id: print("[QUESTIONS] HF_TOKEN ou QUESTIONS_DATASET_ID manquant, dataset non initialisé.", flush=True) return try: create_repo(repo_id=questions_id, repo_type="dataset", private=True, token=token, exist_ok=True) print(f"[QUESTIONS] Dataset questions prêt : {questions_id}", flush=True) except Exception as e: print(f"[QUESTIONS] Erreur init dataset : {e}", flush=True) # Récupérer le fichier existant depuis HF try: path = hf_hub_download(repo_id=questions_id, repo_type="dataset", filename="questions.txt", token=token) with open(path, "r", encoding="utf-8") as src: content = src.read() with open("me/questions.txt", "w", encoding="utf-8") as dst: dst.write(content) print(f"[QUESTIONS] Fichier questions.txt récupéré ({len(content.splitlines())} lignes)", flush=True) except Exception: print("[QUESTIONS] Aucun fichier questions.txt existant sur HF, démarrage à vide.", flush=True) def push_question(question): """Enregistre chaque question posée par un utilisateur dans le dataset Questions.""" now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') print(f"[QUESTIONS] {now} — {question}", flush=True) questions_path = "me/questions.txt" entry = f"[{now}] {question}\n" with open(questions_path, "a", encoding="utf-8") as f: f.write(entry) threading.Thread( target=_upload_to_hf, args=(questions_path, "questions.txt", os.getenv("QUESTIONS_DATASET_ID")), daemon=True, ).start() def push(text): print(f"[LOG] {text}", flush=True) log_path = "me/log.txt" entry = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {text}\n" with open(log_path, "a", encoding="utf-8") as f: f.write(entry) threading.Thread( target=_upload_to_hf, args=(log_path, log_path, os.getenv("LOGS_DATASET_ID")), daemon=True, ).start() def record_user_details(email, name="Name not provided", notes="not provided"): push(f"Recording {name} with email {email} and notes {notes}") return {"recorded": "ok"} def record_unknown_question(question): push(f"Recording {question}") return {"recorded": "ok"} record_user_details_json = { "name": "record_user_details", "description": "Utilise cet outil pour enregistrer qu'un utilisateur souhaite être contacté et a fourni une adresse e-mail", "parameters": { "type": "object", "properties": { "email": { "type": "string", "description": "L'adresse e-mail de cet utilisateur" }, "name": { "type": "string", "description": "Le nom de l'utilisateur, s'il l'a fourni" } , "notes": { "type": "string", "description": "Toute information supplémentaire sur la conversation qui mérite d'être enregistrée pour donner du contexte" } }, "required": ["email"], "additionalProperties": False } } record_unknown_question_json = { "name": "record_unknown_question", "description": "Utilise toujours cet outil pour enregistrer toute question à laquelle tu n'as pas pu répondre faute de connaissance", "parameters": { "type": "object", "properties": { "question": { "type": "string", "description": "La question à laquelle il n'a pas été possible de répondre" }, }, "required": ["question"], "additionalProperties": False } } tools = [{"type": "function", "function": record_user_details_json}, {"type": "function", "function": record_unknown_question_json}] ALLOWED_TOOLS = { "record_user_details": record_user_details, "record_unknown_question": record_unknown_question, } class Me: def __init__(self): self.openai = OpenAI() self.name = "Vikou Nelson" self.linkedin = self._load_knowledge_pdf() self.summary = self._load_knowledge_text("me/summary.txt") self.travaux = self._load_knowledge_text("me/Travaux.txt") remove_local_knowledge_copies() def _load_knowledge_text(self, path_in_repo: str) -> str: cached = _hf_knowledge_file_path(path_in_repo) if cached: print( f"[KNOWLEDGE] {path_in_repo} chargé depuis le dataset HF", flush=True, ) return _read_text_file(cached) if os.path.isfile(path_in_repo): print( f"[KNOWLEDGE] {path_in_repo} chargé depuis me/ (local)", flush=True, ) return _read_text_file(path_in_repo) print(f"[KNOWLEDGE] {path_in_repo} absent.", flush=True) return "" def _load_knowledge_pdf(self) -> str: path_in_repo = "me/cv.pdf" cached = _hf_knowledge_file_path(path_in_repo) if cached: print( f"[KNOWLEDGE] {path_in_repo} chargé depuis le dataset HF", flush=True, ) return _read_pdf_text(cached) if os.path.isfile(path_in_repo): print( f"[KNOWLEDGE] {path_in_repo} chargé depuis me/ (local)", flush=True, ) return _read_pdf_text(path_in_repo) print( "[KNOWLEDGE] me/cv.pdf absent — profil LinkedIn vide.", flush=True, ) return "" def handle_tool_call(self, tool_calls): results = [] for tool_call in tool_calls: tool_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) print(f"Tool called: {tool_name}", flush=True) tool = ALLOWED_TOOLS.get(tool_name) if tool is None: print( f"[SECURITY] Tool non autorisé refusé : {tool_name}", flush=True, ) result = {"error": "tool_not_allowed"} else: result = tool(**arguments) results.append({ "role": "tool", "content": json.dumps(result), "tool_call_id": tool_call.id, }) return results def system_prompt(self): system_prompt = f"You are acting as {self.name}. You are answering questions on {self.name}'s website, \ particularly questions related to {self.name}'s career, background, skills and experience. \ Your responsibility is to represent {self.name} for interactions on the website as faithfully as possible. \ You are given a summary of {self.name}'s background and LinkedIn profile which you can use to answer questions. \ Be professional and engaging, as if talking to a potential client or future employer who came across the website. \ If you don't know the answer to any question, use your record_unknown_question tool to record the question that you couldn't answer, even if it's about something trivial or unrelated to career. \ If the user is engaging in discussion, try to steer them towards getting in touch via email; ask for their email and record it using your record_user_details tool. " system_prompt += f"\n\n## Summary:\n{self.summary}\n\n## LinkedIn Profile:\n{self.linkedin}\n\n## Portfolio & Projects:\n{self.travaux}\n\n" system_prompt += f"With this context, please chat with the user, always staying in character as {self.name}." return system_prompt def chat(self, message, history): push_question(message) history_messages = [] for item in history: if isinstance(item, dict): history_messages.append(item) else: user_msg, assistant_msg = item[0], item[1] history_messages.append({"role": "user", "content": user_msg}) if assistant_msg: history_messages.append({"role": "assistant", "content": assistant_msg}) messages = [{"role": "system", "content": self.system_prompt()}] + history_messages + [{"role": "user", "content": message}] try: done = False while not done: response = self.openai.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=tools) if response.choices[0].finish_reason=="tool_calls": message = response.choices[0].message tool_calls = message.tool_calls results = self.handle_tool_call(tool_calls) messages.append(message) messages.extend(results) else: done = True return response.choices[0].message.content except requests.RequestException: return "Notre service est momentanément indisponible. Veuillez réessayer dans quelques instants." except (KeyError, IndexError, ValueError): return "Une erreur inattendue s'est produite lors du traitement de votre demande. Veuillez reformuler votre question ou réessayer." load_dotenv(override=True) init_knowledge_dataset() init_logs_dataset() init_questions_dataset() me = Me() app = FastAPI() limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler( RateLimitExceeded, _rate_limit_exceeded_handler, ) class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Ajoute des en-têtes de sécurité HTTP standards. Note : on n'utilise pas ``X-Frame-Options`` car cet en-tête ne supporte pas plusieurs origines. On utilise à la place la directive CSP ``frame-ancestors`` qui prend le pas dans les navigateurs modernes et autorise Hugging Face Spaces à embarquer l'app dans un iframe. """ async def dispatch(self, request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = ( "strict-origin-when-cross-origin" ) response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' " "https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "connect-src 'self'; " "frame-ancestors 'self' https://huggingface.co " "https://*.hf.space" ) return response app.add_middleware(SecurityHeadersMiddleware) _origins_env = os.getenv("ALLOWED_ORIGINS", "*") if _origins_env.strip() == "*": _allowed_origins = ["*"] else: _allowed_origins = [ o.strip() for o in _origins_env.split(",") if o.strip() ] app.add_middleware( CORSMiddleware, allow_origins=_allowed_origins, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) class ChatRequest(BaseModel): message: str = Field(..., min_length=1, max_length=2000) history: list = Field(default_factory=list) consent: bool = False @field_validator("history") @classmethod def limit_history(cls, v: list) -> list: if len(v) > 50: return v[-50:] return v @app.post("/chat") @limiter.limit("20/minute") async def chat(request: Request, req: ChatRequest): reply = me.chat(req.message, req.history) return {"response": reply} STUDENTS_UNAVAILABLE_MESSAGE = ( "The student assistant is currently unavailable. " "Please check back later." ) @app.post("/chat/students") @limiter.limit("20/minute") async def chat_students(request: Request, req: ChatRequest): return {"response": STUDENTS_UNAVAILABLE_MESSAGE} app.mount( "/", StaticFiles(directory="static", html=True), name="static", )