| 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)
|
|
|
|
|
|
|
| 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)
|
|
|
| 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)
|
|
|
| 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",
|
| )
|
|
|