File size: 6,506 Bytes
bda6294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# -*- coding: utf-8 -*-
"""
Moteur RAG (Space edition) : recuperation + generation via l'API Hugging Face
(ou Ollama en repli local). Reponses fondees uniquement sur le contenu indexe
du site (public + non public selon les droits).
"""
import pickle
import functools

import requests

import config
import gdpr


@functools.lru_cache(maxsize=1)
def _get_embedder():
    from sentence_transformers import SentenceTransformer
    return SentenceTransformer(config.EMBEDDING_MODEL)


@functools.lru_cache(maxsize=1)
def _get_index_and_chunks():
    import faiss
    index = faiss.read_index(str(config.INDEX_FILE))
    with open(config.CHUNKS_FILE, "rb") as f:
        chunks = pickle.load(f)
    return index, chunks


def reset_cache():
    _get_index_and_chunks.cache_clear()


# --------------------------------------------------------------------------
# Recuperation / retrieval
# --------------------------------------------------------------------------
def retrieve(query, top_k=None):
    top_k = top_k or config.TOP_K
    index, chunks = _get_index_and_chunks()
    embedder = _get_embedder()
    q_vec = embedder.encode([query], normalize_embeddings=True,
                            convert_to_numpy=True).astype("float32")
    scores, idx = index.search(q_vec, top_k)
    results = []
    for score, i in zip(scores[0], idx[0]):
        if i < 0 or score < config.MIN_SCORE:
            continue
        c = dict(chunks[i])
        c["score"] = float(score)
        results.append(c)
    return results


# --------------------------------------------------------------------------
# Prompt
# --------------------------------------------------------------------------
SYSTEM_FR = (
    "Tu es l'assistant interne du site Vizyon Ayiti 360. "
    "Tu reponds EXCLUSIVEMENT a partir du CONTEXTE fourni (extraits du site, "
    "publics et internes). Interdiction d'utiliser des connaissances externes. "
    "Si la reponse ne figure pas dans le contexte, dis-le clairement. Cite les "
    "titres/URL des sources. Reponds dans la langue de la question."
)
SYSTEM_EN = (
    "You are the internal assistant of the Vizyon Ayiti 360 website. "
    "You answer EXCLUSIVELY from the provided CONTEXT (public and internal site "
    "excerpts). Do not use external knowledge. If the answer is not in the "
    "context, say so clearly. Cite source titles/URLs. Reply in the language of "
    "the question."
)
SYSTEM_HT = (
    "Ou se asistan enten sit Vizyon Ayiti 360 a. Ou reponn SELMAN ak KONTEKS yo "
    "ba ou a (ekstre piblik ak enten nan sit la). Ou pa gen dwa itilize okenn "
    "lot konesans deyo. Si repons lan pa nan konteks la, di sa kle. Site tit ak "
    "adres (URL) sous yo. Reponn an kreyol ayisyen."
)
_SYSTEMS = {"fr": SYSTEM_FR, "en": SYSTEM_EN, "ht": SYSTEM_HT}


def build_prompt(query, passages, lang="fr"):
    blocks = []
    for i, p in enumerate(passages, 1):
        tag = "" if p.get("status", "publish") == "publish" else " [INTERNE]"
        blocks.append(f"[Source {i}]{tag} {p['title']} ({p['url']})\n{p['text']}")
    context = "\n\n".join(blocks) if blocks else "(aucun extrait pertinent)"
    system = _SYSTEMS.get(lang, SYSTEM_FR)
    user = (
        f"CONTEXTE:\n{context}\n\n"
        f"QUESTION: {query}\n\n"
        "REPONSE (fondee uniquement sur le contexte, avec citations) :"
    )
    return system, user


# --------------------------------------------------------------------------
# Generation
# --------------------------------------------------------------------------
def _generate_hf(system, user):
    if not config.HF_API_TOKEN:
        raise RuntimeError(
            "Token HF manquant. Ajoutez le secret HF_API_TOKEN (ou HF_TOKEN) "
            "dans les parametres du Space."
        )
    headers = {"Authorization": f"Bearer {config.HF_API_TOKEN}"}
    url = "https://router.huggingface.co/v1/chat/completions"
    payload = {
        "model": config.HF_MODEL,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        "temperature": config.TEMPERATURE,
        "max_tokens": config.MAX_TOKENS,
    }
    r = requests.post(url, headers=headers, json=payload, timeout=180)
    r.raise_for_status()
    return r.json()["choices"][0]["message"]["content"].strip()


def _generate_ollama(system, user):
    payload = {
        "model": config.OLLAMA_MODEL,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        "stream": False,
        "options": {"temperature": config.TEMPERATURE,
                    "num_predict": config.MAX_TOKENS},
    }
    r = requests.post(f"{config.OLLAMA_HOST}/api/chat", json=payload, timeout=180)
    r.raise_for_status()
    return r.json()["message"]["content"].strip()


def generate(system, user):
    if config.LLM_BACKEND == "ollama":
        return _generate_ollama(system, user)
    return _generate_hf(system, user)


# --------------------------------------------------------------------------
# Point d'entree / entry point
# --------------------------------------------------------------------------
def answer(query, lang="fr", extra_context=""):
    query = gdpr.safe_for_processing(query)
    passages = retrieve(query)
    if extra_context:
        passages = passages + [{
            "title": "Document televerse (session)",
            "url": "local://document",
            "text": extra_context[:4000],
            "score": 1.0,
            "type": "Document",
            "status": "publish",
        }]
    if not passages:
        _no = {
            "fr": ("Je n'ai trouve aucune information correspondante dans le "
                   "contenu indexe du site. Reformulez votre question."),
            "en": ("I could not find matching information in the indexed site "
                   "content. Try rephrasing your question."),
            "ht": ("Mwen pa jwenn okenn enfomasyon ki koresponn nan kontni sit "
                   "la. Eseye poze kesyon an yon lot jan."),
        }
        return _no.get(lang, _no["fr"]), []
    system, user = build_prompt(query, passages, lang)
    response = generate(system, user)
    sources = [{"title": p["title"], "url": p["url"],
                "score": p.get("score", 0),
                "status": p.get("status", "publish")}
               for p in passages if p["url"] != "local://document"]
    gdpr.log_interaction(query, response, sources)
    return response, sources