File size: 18,927 Bytes
0e45eeb
571c640
0e45eeb
 
 
571c640
 
 
 
 
 
 
 
 
 
0e45eeb
571c640
 
 
 
 
 
 
0e45eeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571c640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e45eeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571c640
 
 
 
 
 
0e45eeb
 
 
 
 
571c640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e45eeb
 
 
 
 
 
 
 
571c640
 
 
 
 
 
 
 
 
 
 
 
 
 
0e45eeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571c640
0e45eeb
 
 
 
 
 
571c640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e45eeb
 
571c640
 
0e45eeb
 
 
571c640
0e45eeb
571c640
 
 
 
 
 
 
 
 
 
 
0e45eeb
 
571c640
 
0e45eeb
 
 
571c640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
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",
)