File size: 8,769 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
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
# -*- coding: utf-8 -*-
"""
Ingestion du contenu du site via l'API REST WordPress (public + non public),
puis construction d'un index vectoriel FAISS.
Ingest website content via the WordPress REST API (public + non-public),
then build a FAISS index.

Le contenu NON public (pages/articles prives, brouillons, medias proteges) est
recupere uniquement si des identifiants WordPress valides sont fournis
(mot de passe d'application). / Non-public content is fetched only when valid
WordPress credentials (Application Password) are provided.
"""
import re
import json
import pickle
import html
import time

import requests
from requests.auth import HTTPBasicAuth

import config


# --------------------------------------------------------------------------
# Session authentifiee / authenticated session
# --------------------------------------------------------------------------
def _make_session():
    session = requests.Session()
    session.headers.update({"User-Agent": "VizyonAyiti360-Assistant/1.0"})
    authenticated = False
    if (config.INCLUDE_NON_PUBLIC and config.WP_USERNAME
            and config.WP_APP_PASSWORD):
        # Le mot de passe d'application peut contenir des espaces : on les garde.
        session.auth = HTTPBasicAuth(config.WP_USERNAME, config.WP_APP_PASSWORD)
        authenticated = True
    return session, authenticated


# --------------------------------------------------------------------------
# Nettoyage HTML / HTML cleaning
# --------------------------------------------------------------------------
def _strip_html(raw: str) -> str:
    if not raw:
        return ""
    raw = re.sub(r"(?s)<(script|style).*?</\1>", " ", raw)
    raw = re.sub(r"(?s)<!--.*?-->", " ", raw)
    text = re.sub(r"(?s)<[^>]+>", " ", raw)
    text = html.unescape(text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


# --------------------------------------------------------------------------
# Recuperation d'un type de contenu / fetch a content type
# --------------------------------------------------------------------------
def fetch_content_type(rest_base, label, session, authenticated):
    items = []
    page = 1
    params_base = {"per_page": 100,
                   "_fields": "id,link,title,content,excerpt,date,type,status"}
    # Si authentifie : contexte edition + statuts non publics.
    if authenticated:
        params_base["context"] = "edit"
        params_base["status"] = config.WP_STATUSES
    while True:
        params = dict(params_base, page=page)
        url = f"{config.WP_API_BASE}/{rest_base}"
        try:
            r = session.get(url, params=params, timeout=30)
        except requests.RequestException as e:
            print(f"  [!] {rest_base} page {page}: {e}")
            break
        if r.status_code in (400, 401, 403):
            # 400 = au-dela de la derniere page ; 401/403 = pas les droits
            # pour ce statut -> on retente en public seulement.
            if authenticated and page == 1 and r.status_code in (401, 403):
                print(f"  [!] {rest_base}: acces non-public refuse, repli public.")
                params_base.pop("context", None)
                params_base.pop("status", None)
                authenticated = False
                continue
            break
        if r.status_code != 200:
            print(f"  [!] {rest_base}: HTTP {r.status_code}")
            break
        batch = r.json()
        if not isinstance(batch, list) or not batch:
            break
        for it in batch:
            title = _strip_html((it.get("title") or {}).get("rendered", "")
                                or (it.get("title") or {}).get("raw", ""))
            content = it.get("content") or {}
            body = _strip_html(content.get("rendered", "")
                               or content.get("raw", ""))
            if not body:
                excerpt = it.get("excerpt") or {}
                body = _strip_html(excerpt.get("rendered", "")
                                   or excerpt.get("raw", ""))
            if not body and not title:
                continue
            status = it.get("status", "publish")
            items.append({
                "id": f"{rest_base}-{it.get('id')}",
                "type": label,
                "status": status,
                "title": title or "(sans titre)",
                "url": it.get("link", config.SITE_URL),
                "date": it.get("date", ""),
                "text": f"{title}. {body}".strip(),
            })
        total_pages = int(r.headers.get("X-WP-TotalPages", page))
        if page >= total_pages:
            break
        page += 1
        time.sleep(0.2)
    npub = sum(1 for i in items if i["status"] != "publish")
    print(f"  [+] {label}: {len(items)} element(s) ({npub} non-public(s))")
    return items


def fetch_site():
    session, authenticated = _make_session()
    if authenticated:
        print("  [i] Authentification WordPress active (contenu non-public inclus).")
    else:
        print("  [i] Mode public uniquement (pas d'identifiants WordPress).")
    documents = []
    for rest_base, label in config.WP_CONTENT_TYPES.items():
        documents.extend(fetch_content_type(rest_base, label, session,
                                            authenticated))
    return documents


# --------------------------------------------------------------------------
# Decoupage / chunking
# --------------------------------------------------------------------------
def chunk_text(text, size, overlap):
    text = text.strip()
    if len(text) <= size:
        return [text] if text else []
    chunks, start = [], 0
    while start < len(text):
        end = start + size
        chunk = text[start:end]
        if end < len(text):
            last_dot = chunk.rfind(". ")
            if last_dot > size * 0.5:
                chunk = chunk[:last_dot + 1]
                end = start + last_dot + 1
        chunks.append(chunk.strip())
        start = end - overlap
    return [c for c in chunks if c]


def build_chunks(documents):
    chunks = []
    for doc in documents:
        for i, part in enumerate(chunk_text(doc["text"], config.CHUNK_SIZE,
                                            config.CHUNK_OVERLAP)):
            chunks.append({
                "chunk_id": f"{doc['id']}-{i}",
                "type": doc["type"],
                "status": doc.get("status", "publish"),
                "title": doc["title"],
                "url": doc["url"],
                "date": doc["date"],
                "text": part,
            })
    return chunks


# --------------------------------------------------------------------------
# Index FAISS
# --------------------------------------------------------------------------
def build_index(progress_cb=None):
    from sentence_transformers import SentenceTransformer
    import faiss

    def _say(msg):
        print(msg)
        if progress_cb:
            progress_cb(msg)

    _say("Recuperation du contenu du site (API REST WordPress)...")
    documents = fetch_site()
    if not documents:
        raise RuntimeError("Aucun contenu recupere depuis le site.")

    _say(f"Decoupage de {len(documents)} document(s)...")
    chunks = build_chunks(documents)
    _say(f"{len(chunks)} passage(s) a indexer.")

    _say(f"Chargement des embeddings : {config.EMBEDDING_MODEL}")
    model = SentenceTransformer(config.EMBEDDING_MODEL)

    _say("Calcul des vecteurs...")
    texts = [c["text"] for c in chunks]
    vectors = model.encode(texts, batch_size=32, show_progress_bar=False,
                           normalize_embeddings=True,
                           convert_to_numpy=True).astype("float32")

    dim = vectors.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(vectors)

    faiss.write_index(index, str(config.INDEX_FILE))
    with open(config.CHUNKS_FILE, "wb") as f:
        pickle.dump(chunks, f)
    meta = {
        "built_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "site": config.SITE_URL,
        "documents": len(documents),
        "chunks": len(chunks),
        "non_public": sum(1 for c in chunks if c.get("status") != "publish"),
        "embedding_model": config.EMBEDDING_MODEL,
        "dim": dim,
    }
    with open(config.META_FILE, "w", encoding="utf-8") as f:
        json.dump(meta, f, ensure_ascii=False, indent=2)
    _say(f"Index construit : {len(chunks)} passages.")
    return meta


def index_exists():
    return config.INDEX_FILE.exists() and config.CHUNKS_FILE.exists()


def load_meta():
    if config.META_FILE.exists():
        with open(config.META_FILE, encoding="utf-8") as f:
            return json.load(f)
    return None


if __name__ == "__main__":
    print(json.dumps(build_index(), ensure_ascii=False, indent=2))