Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- config.py +5 -2
- embeddings.py +190 -40
- embeddings_v1.py +247 -0
- models.py +17 -2
config.py
CHANGED
|
@@ -10,11 +10,14 @@ DATA_DIR: Path = BASE_DIR / "data"
|
|
| 10 |
FAQ_JSON_PATH: str = str(DATA_DIR / "260717_FAQ_Miss_Terry_developpeur.json")
|
| 11 |
|
| 12 |
# Configuration des embeddings
|
| 13 |
-
MODEL_NAME: str = os.getenv("MODEL_NAME", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
| 14 |
|
| 15 |
# Configuration ChromaDB
|
| 16 |
CHROMA_COLLECTION_NAME: str = "faq"
|
| 17 |
|
| 18 |
# Configuration de similarité
|
| 19 |
-
SIMILARITY_THRESHOLD: float = float(os.getenv("SIMILARITY_THRESHOLD", "0.
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
FAQ_JSON_PATH: str = str(DATA_DIR / "260717_FAQ_Miss_Terry_developpeur.json")
|
| 11 |
|
| 12 |
# Configuration des embeddings
|
| 13 |
+
MODEL_NAME: str = os.getenv("MODEL_NAME", "intfloat/multilingual-e5-small") #"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
| 14 |
|
| 15 |
# Configuration ChromaDB
|
| 16 |
CHROMA_COLLECTION_NAME: str = "faq"
|
| 17 |
|
| 18 |
# Configuration de similarité
|
| 19 |
+
SIMILARITY_THRESHOLD: float = float(os.getenv("SIMILARITY_THRESHOLD", "0.80"))
|
| 20 |
|
| 21 |
+
TOP_K = 3
|
| 22 |
+
CROSS_ENCODER_MODEL_NAME = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
|
| 23 |
+
CROSS_ENCODER_THRESHOLD = 0.5
|
embeddings.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Gestion des embeddings avec ChromaDB et Hugging Face Transformers."""
|
| 2 |
import os
|
| 3 |
-
# Règle d
|
| 4 |
-
# définie avant d
|
| 5 |
cache_dir = "/tmp"
|
| 6 |
os.makedirs(cache_dir, exist_ok=True)
|
| 7 |
|
|
@@ -10,23 +10,30 @@ os.environ["HF_HOME"] = cache_dir
|
|
| 10 |
os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
|
| 11 |
os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
|
|
|
|
|
|
|
|
|
| 17 |
import chromadb
|
| 18 |
-
from sentence_transformers import SentenceTransformer
|
| 19 |
import torch
|
| 20 |
from config import (
|
| 21 |
CHROMA_COLLECTION_NAME,
|
| 22 |
MODEL_NAME,
|
| 23 |
SIMILARITY_THRESHOLD,
|
|
|
|
|
|
|
|
|
|
| 24 |
)
|
| 25 |
from faq_loader import FAQEntry
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
-
|
| 30 |
class EmbeddingManager:
|
| 31 |
"""Gestionnaire des embeddings et de ChromaDB."""
|
| 32 |
|
|
@@ -34,6 +41,7 @@ class EmbeddingManager:
|
|
| 34 |
self,
|
| 35 |
model_name: str = MODEL_NAME,
|
| 36 |
collection_name: str = CHROMA_COLLECTION_NAME,
|
|
|
|
| 37 |
) -> None:
|
| 38 |
"""
|
| 39 |
Initialiser le gestionnaire d'embeddings.
|
|
@@ -41,13 +49,34 @@ class EmbeddingManager:
|
|
| 41 |
Args:
|
| 42 |
model_name: Nom du modèle Sentence-Transformers à utiliser
|
| 43 |
collection_name: Nom de la collection ChromaDB
|
|
|
|
| 44 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
# Creation du Sentence transformer model
|
| 47 |
logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
|
| 48 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 49 |
self.model = SentenceTransformer(MODEL_NAME, device=device)
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
# Initialiser ChromaDB en mémoire (EphemeralClient)
|
| 52 |
# Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
|
| 53 |
logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
|
|
@@ -80,7 +109,7 @@ class EmbeddingManager:
|
|
| 80 |
self.collection.delete(ids=existing_ids)
|
| 81 |
|
| 82 |
# Générer les embeddings pour toutes les formulations
|
| 83 |
-
formulations: list[str] = [entry.formulation for entry in faq_entries]
|
| 84 |
logger.info("Génération des embeddings...")
|
| 85 |
embeddings = self.model.encode(formulations, show_progress_bar=True,
|
| 86 |
convert_to_numpy=True, normalize_embeddings=True)
|
|
@@ -102,29 +131,102 @@ class EmbeddingManager:
|
|
| 102 |
|
| 103 |
logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
|
| 106 |
"""
|
| 107 |
-
Rechercher dans la FAQ la formulation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
Args:
|
| 110 |
-
|
| 111 |
-
|
| 112 |
|
| 113 |
Returns:
|
| 114 |
-
|
| 115 |
"""
|
| 116 |
-
# Récupérer la question et
|
| 117 |
question = payload.question
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
# Générer l'embedding de la question
|
| 121 |
-
question_embedding = self.model.encode(
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
# Rechercher dans ChromaDB
|
| 125 |
results = self.collection.query(
|
| 126 |
query_embeddings=question_embedding,
|
| 127 |
-
n_results=
|
| 128 |
include=["documents", "metadatas", "distances"]
|
| 129 |
)
|
| 130 |
|
|
@@ -133,48 +235,84 @@ class EmbeddingManager:
|
|
| 133 |
return AnswerOutput(
|
| 134 |
question=question,
|
| 135 |
formulation="unknown",
|
| 136 |
-
answer="Je ne dispose pas d'
|
| 137 |
theme="unknown",
|
| 138 |
similarity_score=0.0,
|
| 139 |
confidence=False,
|
| 140 |
)
|
| 141 |
|
| 142 |
-
#
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
theme = metadata["theme"]
|
| 151 |
-
response = metadata["response"]
|
| 152 |
|
| 153 |
-
|
| 154 |
-
if similarity_score < threshold:
|
| 155 |
logger.info(
|
| 156 |
-
f"
|
| 157 |
f"Question: {question}"
|
| 158 |
)
|
| 159 |
return AnswerOutput(
|
| 160 |
question=question,
|
| 161 |
-
formulation=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
|
| 163 |
-
theme=theme,
|
| 164 |
-
similarity_score=round(similarity_score, 4),
|
|
|
|
|
|
|
| 165 |
confidence=False,
|
| 166 |
)
|
| 167 |
|
| 168 |
logger.info(
|
| 169 |
-
f"Match trouvé - Theme: {theme},
|
|
|
|
| 170 |
)
|
| 171 |
|
| 172 |
return AnswerOutput(
|
| 173 |
question=question,
|
| 174 |
-
formulation=formulation,
|
| 175 |
-
answer=response,
|
| 176 |
-
theme=theme,
|
| 177 |
-
similarity_score=round(similarity_score, 4),
|
|
|
|
|
|
|
| 178 |
confidence=True,
|
| 179 |
)
|
| 180 |
|
|
@@ -186,3 +324,15 @@ class EmbeddingManager:
|
|
| 186 |
Nombre de documents
|
| 187 |
"""
|
| 188 |
return self.collection.count()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Gestion des embeddings avec ChromaDB et Hugging Face Transformers."""
|
| 2 |
import os
|
| 3 |
+
# Règle d'or : toute variable d'environnement qui influence le cache Hugging Face doit être
|
| 4 |
+
# définie avant d'importer datasets ou transformers, sinon elle sera ignorée.
|
| 5 |
cache_dir = "/tmp"
|
| 6 |
os.makedirs(cache_dir, exist_ok=True)
|
| 7 |
|
|
|
|
| 10 |
os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
|
| 11 |
os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")
|
| 12 |
|
| 13 |
+
# Désactiver la télémétrie ChromaDB/PostHog pour éviter les erreurs de signature
|
| 14 |
+
# de capture() sur certaines versions de dependent packages.
|
| 15 |
+
os.environ.setdefault("CHROMA_DISABLE_TELEMETRY", "1")
|
| 16 |
+
os.environ.setdefault("CHROMADB_DISABLE_TELEMETRY", "1")
|
| 17 |
+
os.environ.setdefault("POSTHOG_DISABLED", "1")
|
| 18 |
|
| 19 |
+
import logging
|
| 20 |
+
from models import QuestionInput, AnswerOutput, SemanticSearchCandidate
|
| 21 |
+
import unicodedata
|
| 22 |
import chromadb
|
| 23 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder
|
| 24 |
import torch
|
| 25 |
from config import (
|
| 26 |
CHROMA_COLLECTION_NAME,
|
| 27 |
MODEL_NAME,
|
| 28 |
SIMILARITY_THRESHOLD,
|
| 29 |
+
TOP_K,
|
| 30 |
+
CROSS_ENCODER_MODEL_NAME,
|
| 31 |
+
CROSS_ENCODER_THRESHOLD,
|
| 32 |
)
|
| 33 |
from faq_loader import FAQEntry
|
| 34 |
|
| 35 |
logger = logging.getLogger(__name__)
|
| 36 |
|
|
|
|
| 37 |
class EmbeddingManager:
|
| 38 |
"""Gestionnaire des embeddings et de ChromaDB."""
|
| 39 |
|
|
|
|
| 41 |
self,
|
| 42 |
model_name: str = MODEL_NAME,
|
| 43 |
collection_name: str = CHROMA_COLLECTION_NAME,
|
| 44 |
+
cross_encoder_model_name: str = CROSS_ENCODER_MODEL_NAME,
|
| 45 |
) -> None:
|
| 46 |
"""
|
| 47 |
Initialiser le gestionnaire d'embeddings.
|
|
|
|
| 49 |
Args:
|
| 50 |
model_name: Nom du modèle Sentence-Transformers à utiliser
|
| 51 |
collection_name: Nom de la collection ChromaDB
|
| 52 |
+
cross_encoder_model_name: Nom du modèle Cross-Encoder utilisé pour le re-ranking
|
| 53 |
"""
|
| 54 |
+
# Table de traduction pré-compilée une seule fois (au chargement du module)
|
| 55 |
+
self._TRANSLATION_TABLE = str.maketrans({
|
| 56 |
+
"’": "'", "‘": "'",
|
| 57 |
+
"«": '"', "»": '"',
|
| 58 |
+
"“": '"', "”": '"',
|
| 59 |
+
"–": "-", "—": "-",
|
| 60 |
+
"?": ""
|
| 61 |
+
})
|
| 62 |
+
if "-e5-" in model_name:
|
| 63 |
+
logger.info(f"Modèle {model_name} détecté comme modèle E5.")
|
| 64 |
+
self._prefix_p = "passage: "
|
| 65 |
+
self._prefix_q = "query: "
|
| 66 |
+
else:
|
| 67 |
+
self._prefix_p = ""
|
| 68 |
+
self._prefix_q = ""
|
| 69 |
|
| 70 |
# Creation du Sentence transformer model
|
| 71 |
logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
|
| 72 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 73 |
self.model = SentenceTransformer(MODEL_NAME, device=device)
|
| 74 |
|
| 75 |
+
# Création du modèle Cross-Encoder utilisé pour le re-ranking
|
| 76 |
+
logger.info(f"Initialisation du modèle Cross-Encoder: {cross_encoder_model_name}")
|
| 77 |
+
self.cross_encoder = CrossEncoder(cross_encoder_model_name, device=device)
|
| 78 |
+
self.cross_encoder_model_name: str = cross_encoder_model_name
|
| 79 |
+
|
| 80 |
# Initialiser ChromaDB en mémoire (EphemeralClient)
|
| 81 |
# Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
|
| 82 |
logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
|
|
|
|
| 109 |
self.collection.delete(ids=existing_ids)
|
| 110 |
|
| 111 |
# Générer les embeddings pour toutes les formulations
|
| 112 |
+
formulations: list[str] = [self._prefix_p + self._normalize_text(entry.formulation) for entry in faq_entries]
|
| 113 |
logger.info("Génération des embeddings...")
|
| 114 |
embeddings = self.model.encode(formulations, show_progress_bar=True,
|
| 115 |
convert_to_numpy=True, normalize_embeddings=True)
|
|
|
|
| 131 |
|
| 132 |
logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")
|
| 133 |
|
| 134 |
+
def _filter_and_dedup_by_theme(
|
| 135 |
+
self,
|
| 136 |
+
documents: list[str],
|
| 137 |
+
metadatas: list[dict],
|
| 138 |
+
distances: list[float],
|
| 139 |
+
similarity_threshold: float,
|
| 140 |
+
top_k: int,
|
| 141 |
+
) -> list[SemanticSearchCandidate]:
|
| 142 |
+
"""
|
| 143 |
+
Filtre les candidats Chroma par seuil de similarité cosinus, puis conserve
|
| 144 |
+
une seule formulation (la meilleure) par thème, jusqu'à obtenir `top_k`
|
| 145 |
+
thèmes distincts.
|
| 146 |
+
|
| 147 |
+
Les résultats de Chroma étant triés par similarité décroissante, la
|
| 148 |
+
fonction s'arrête dès que le seuil n'est plus atteint ou que `top_k`
|
| 149 |
+
thèmes distincts ont été retenus.
|
| 150 |
+
"""
|
| 151 |
+
seen_themes: set[str] = set()
|
| 152 |
+
candidates: list[SemanticSearchCandidate] = []
|
| 153 |
+
|
| 154 |
+
for document, metadata, distance in zip(
|
| 155 |
+
documents,
|
| 156 |
+
metadatas,
|
| 157 |
+
distances,
|
| 158 |
+
strict=True,
|
| 159 |
+
):
|
| 160 |
+
similarity = 1.0 - distance
|
| 161 |
+
|
| 162 |
+
# Les résultats étant triés par similarité décroissante,
|
| 163 |
+
# tous les suivants seront également sous le seuil.
|
| 164 |
+
if similarity < similarity_threshold:
|
| 165 |
+
break
|
| 166 |
+
|
| 167 |
+
theme = metadata["theme"]
|
| 168 |
+
|
| 169 |
+
if theme not in seen_themes:
|
| 170 |
+
seen_themes.add(theme)
|
| 171 |
+
|
| 172 |
+
candidates.append(
|
| 173 |
+
SemanticSearchCandidate(
|
| 174 |
+
formulation=document,
|
| 175 |
+
theme=theme,
|
| 176 |
+
response=metadata["response"],
|
| 177 |
+
similarity_score=similarity,
|
| 178 |
+
)
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if len(candidates) == top_k:
|
| 182 |
+
break
|
| 183 |
+
|
| 184 |
+
return candidates
|
| 185 |
+
|
| 186 |
def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
|
| 187 |
"""
|
| 188 |
+
Rechercher dans la FAQ la formulation la plus pertinente pour une question donnée.
|
| 189 |
+
|
| 190 |
+
Étapes :
|
| 191 |
+
1. Récupération des candidats dans ChromaDB (similarité cosinus), filtrés
|
| 192 |
+
par SIMILARITY_THRESHOLD et dédupliqués pour ne garder qu'une
|
| 193 |
+
formulation par thème, limités à top_k.
|
| 194 |
+
2. Re-ranking de ces top_k candidats avec un Cross-Encoder (question, formulation).
|
| 195 |
+
3. Retour de la réponse associée au meilleur score Cross-Encoder si celui-ci
|
| 196 |
+
dépasse le seuil fourni, sinon message de repli.
|
| 197 |
|
| 198 |
Args:
|
| 199 |
+
payload: QuestionInput contenant la question, et éventuellement
|
| 200 |
+
threshold (cosinus), top_k et le seuil cross-encoder.
|
| 201 |
|
| 202 |
Returns:
|
| 203 |
+
AnswerOutput
|
| 204 |
"""
|
| 205 |
+
# Récupérer la question et les paramètres depuis le payload
|
| 206 |
question = payload.question
|
| 207 |
+
similarity_threshold = payload.similarity_threshold if payload.similarity_threshold is not None else SIMILARITY_THRESHOLD
|
| 208 |
+
top_k = getattr(payload, "top_k", None) or TOP_K
|
| 209 |
+
cross_encoder_threshold = (
|
| 210 |
+
getattr(payload, "cross_encoder_threshold", None)
|
| 211 |
+
if getattr(payload, "cross_encoder_threshold", None) is not None
|
| 212 |
+
else CROSS_ENCODER_THRESHOLD
|
| 213 |
+
)
|
| 214 |
|
| 215 |
# Générer l'embedding de la question
|
| 216 |
+
question_embedding = self.model.encode(
|
| 217 |
+
[self._prefix_q + self._normalize_text(question)],
|
| 218 |
+
convert_to_numpy=True,
|
| 219 |
+
normalize_embeddings=True,
|
| 220 |
+
).tolist()
|
| 221 |
+
|
| 222 |
+
# On récupère un pool de candidats plus large que top_k auprès de Chroma,
|
| 223 |
+
# car plusieurs formulations peuvent partager le même thème et doivent être
|
| 224 |
+
# dédupliquées avant d'atteindre top_k thèmes distincts.
|
| 225 |
+
n_candidates = max(top_k * 5, 20)
|
| 226 |
|
|
|
|
| 227 |
results = self.collection.query(
|
| 228 |
query_embeddings=question_embedding,
|
| 229 |
+
n_results=n_candidates,
|
| 230 |
include=["documents", "metadatas", "distances"]
|
| 231 |
)
|
| 232 |
|
|
|
|
| 235 |
return AnswerOutput(
|
| 236 |
question=question,
|
| 237 |
formulation="unknown",
|
| 238 |
+
answer="Je ne dispose pas d'éléments de réponse pour cette question.",
|
| 239 |
theme="unknown",
|
| 240 |
similarity_score=0.0,
|
| 241 |
confidence=False,
|
| 242 |
)
|
| 243 |
|
| 244 |
+
# Filtrer par seuil cosinus et dédupliquer par thème (top_k thèmes distincts)
|
| 245 |
+
candidates = self._filter_and_dedup_by_theme(
|
| 246 |
+
documents=results["documents"][0],
|
| 247 |
+
metadatas=results["metadatas"][0],
|
| 248 |
+
distances=results["distances"][0],
|
| 249 |
+
similarity_threshold=similarity_threshold,
|
| 250 |
+
top_k=top_k,
|
| 251 |
+
)
|
|
|
|
|
|
|
| 252 |
|
| 253 |
+
if not candidates:
|
|
|
|
| 254 |
logger.info(
|
| 255 |
+
f"Aucun candidat au-dessus du seuil cosinus {similarity_threshold}. "
|
| 256 |
f"Question: {question}"
|
| 257 |
)
|
| 258 |
return AnswerOutput(
|
| 259 |
question=question,
|
| 260 |
+
formulation="unknown",
|
| 261 |
+
answer="Je ne dispose pas d'éléments de réponse pour cette question.",
|
| 262 |
+
theme="unknown",
|
| 263 |
+
similarity_score=0.0,
|
| 264 |
+
confidence=False,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# Appliquer le Cross-Encoder entre la question et chaque formulation candidate
|
| 268 |
+
pairs = [(question, candidate.formulation) for candidate in candidates]
|
| 269 |
+
cross_scores = self.cross_encoder.predict(pairs)
|
| 270 |
+
|
| 271 |
+
for candidate, cross_score in zip(candidates, cross_scores):
|
| 272 |
+
candidate.cross_encoder_score = float(cross_score)
|
| 273 |
+
|
| 274 |
+
# Sélectionner le meilleur score cross-encoder
|
| 275 |
+
best_candidate = max(
|
| 276 |
+
candidates,
|
| 277 |
+
key=lambda c: c.cross_encoder_score if c.cross_encoder_score is not None else float("-inf"),
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
logger.info(
|
| 281 |
+
"Candidats re-classés par cross-encoder: "
|
| 282 |
+
+ ", ".join(
|
| 283 |
+
f"[{c.theme}: {c.cross_encoder_score:.3f}]" for c in candidates
|
| 284 |
+
)
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
if best_candidate.cross_encoder_score is None or best_candidate.cross_encoder_score < cross_encoder_threshold:
|
| 288 |
+
logger.info(
|
| 289 |
+
f"Meilleur score cross-encoder {best_candidate.cross_encoder_score:.3f} "
|
| 290 |
+
f"< seuil {cross_encoder_threshold}. Question: {question}"
|
| 291 |
+
)
|
| 292 |
+
return AnswerOutput(
|
| 293 |
+
question=question,
|
| 294 |
+
formulation=best_candidate.formulation,
|
| 295 |
answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
|
| 296 |
+
theme=best_candidate.theme,
|
| 297 |
+
similarity_score=round(best_candidate.similarity_score or 0.0, 4),
|
| 298 |
+
cross_encoder_score=round(best_candidate.cross_encoder_score or 0.0, 4),
|
| 299 |
+
list_candidates=candidates,
|
| 300 |
confidence=False,
|
| 301 |
)
|
| 302 |
|
| 303 |
logger.info(
|
| 304 |
+
f"Match trouvé - Theme: {best_candidate.theme}, "
|
| 305 |
+
f"Score cross-encoder: {best_candidate.cross_encoder_score:.3f}"
|
| 306 |
)
|
| 307 |
|
| 308 |
return AnswerOutput(
|
| 309 |
question=question,
|
| 310 |
+
formulation=best_candidate.formulation,
|
| 311 |
+
answer=best_candidate.response,
|
| 312 |
+
theme=best_candidate.theme,
|
| 313 |
+
similarity_score=round(best_candidate.similarity_score or 0.0, 4),
|
| 314 |
+
cross_encoder_score=round(best_candidate.cross_encoder_score or 0.0, 4),
|
| 315 |
+
list_candidates=candidates,
|
| 316 |
confidence=True,
|
| 317 |
)
|
| 318 |
|
|
|
|
| 324 |
Nombre de documents
|
| 325 |
"""
|
| 326 |
return self.collection.count()
|
| 327 |
+
|
| 328 |
+
def _normalize_text(self, text: str) -> str:
|
| 329 |
+
# Uniformise les apostrophes et guillemets typographiques
|
| 330 |
+
text = text.translate(self._TRANSLATION_TABLE)
|
| 331 |
+
|
| 332 |
+
# Normalise les caractères unicode équivalents (NFC)
|
| 333 |
+
text = unicodedata.normalize("NFC", text)
|
| 334 |
+
|
| 335 |
+
# Espaces multiples, trim (déjà optimal : split()+join en C)
|
| 336 |
+
text = " ".join(text.split())
|
| 337 |
+
|
| 338 |
+
return text
|
embeddings_v1.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gestion des embeddings avec ChromaDB et Hugging Face Transformers."""
|
| 2 |
+
import os
|
| 3 |
+
# Règle d’or : toute variable d’environnement qui influence le cache Hugging Face doit être
|
| 4 |
+
# définie avant d’importer datasets ou transformers, sinon elle sera ignorée.
|
| 5 |
+
cache_dir = "/tmp"
|
| 6 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 7 |
+
|
| 8 |
+
# Rediriger le cache HF globalement
|
| 9 |
+
os.environ["HF_HOME"] = cache_dir
|
| 10 |
+
os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
|
| 11 |
+
os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")
|
| 12 |
+
|
| 13 |
+
# Désactiver la télémétrie ChromaDB/PostHog pour éviter les erreurs de signature
|
| 14 |
+
# de capture() sur certaines versions de dependent packages.
|
| 15 |
+
os.environ.setdefault("CHROMA_DISABLE_TELEMETRY", "1")
|
| 16 |
+
os.environ.setdefault("CHROMADB_DISABLE_TELEMETRY", "1")
|
| 17 |
+
os.environ.setdefault("POSTHOG_DISABLED", "1")
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Optional
|
| 21 |
+
from models import QuestionInput, AnswerOutput
|
| 22 |
+
import unicodedata
|
| 23 |
+
import chromadb
|
| 24 |
+
from sentence_transformers import SentenceTransformer
|
| 25 |
+
import torch
|
| 26 |
+
from config import (
|
| 27 |
+
CHROMA_COLLECTION_NAME,
|
| 28 |
+
MODEL_NAME,
|
| 29 |
+
SIMILARITY_THRESHOLD,
|
| 30 |
+
)
|
| 31 |
+
from faq_loader import FAQEntry
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class EmbeddingManager:
|
| 37 |
+
"""Gestionnaire des embeddings et de ChromaDB."""
|
| 38 |
+
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
model_name: str = MODEL_NAME,
|
| 42 |
+
collection_name: str = CHROMA_COLLECTION_NAME,
|
| 43 |
+
) -> None:
|
| 44 |
+
"""
|
| 45 |
+
Initialiser le gestionnaire d'embeddings.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
model_name: Nom du modèle Sentence-Transformers à utiliser
|
| 49 |
+
collection_name: Nom de la collection ChromaDB
|
| 50 |
+
"""
|
| 51 |
+
# Table de traduction pré-compilée une seule fois (au chargement du module)
|
| 52 |
+
self._TRANSLATION_TABLE = str.maketrans({
|
| 53 |
+
"’": "'", "‘": "'",
|
| 54 |
+
"«": '"', "»": '"',
|
| 55 |
+
"“": '"', "”": '"',
|
| 56 |
+
"–": "-", "—": "-",
|
| 57 |
+
"?": ""
|
| 58 |
+
})
|
| 59 |
+
if "-e5-" in model_name:
|
| 60 |
+
logger.info(f"Modèle {model_name} détecté comme modèle E5.")
|
| 61 |
+
self._prefix_p = "passage: "
|
| 62 |
+
self._prefix_q = "query: "
|
| 63 |
+
else:
|
| 64 |
+
self._prefix_p = ""
|
| 65 |
+
self._prefix_q = ""
|
| 66 |
+
|
| 67 |
+
# Creation du Sentence transformer model
|
| 68 |
+
logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
|
| 69 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 70 |
+
self.model = SentenceTransformer(MODEL_NAME, device=device)
|
| 71 |
+
|
| 72 |
+
# Initialiser ChromaDB en mémoire (EphemeralClient)
|
| 73 |
+
# Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
|
| 74 |
+
logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
|
| 75 |
+
self.client: chromadb.Client = chromadb.EphemeralClient()
|
| 76 |
+
|
| 77 |
+
# Créer ou récupérer la collection
|
| 78 |
+
self.collection = self.client.get_or_create_collection(
|
| 79 |
+
name=collection_name,
|
| 80 |
+
embedding_function=None,
|
| 81 |
+
metadata={"hnsw:space": "ip"} # Utiliser produit scalaire => normaliser les embeddings
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
self.collection_name: str = collection_name
|
| 85 |
+
logger.info(f"✓ EmbeddingManager initialisé (collection: {collection_name})")
|
| 86 |
+
|
| 87 |
+
def populate_collection(self, faq_entries: list[FAQEntry]) -> None:
|
| 88 |
+
"""
|
| 89 |
+
Remplir la collection ChromaDB avec les embeddings des FAQ.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
faq_entries: Liste d'objets FAQEntry à indexer
|
| 93 |
+
"""
|
| 94 |
+
logger.info(f"Population de ChromaDB avec {len(faq_entries)} entrées...")
|
| 95 |
+
|
| 96 |
+
# Vider la collection existante (optionnel, mais plus propre)
|
| 97 |
+
# Récupérer l'ID de collection pour supprimer les anciens documents
|
| 98 |
+
existing_ids = self.collection.get()["ids"]
|
| 99 |
+
if existing_ids:
|
| 100 |
+
logger.info(f"Suppression de {len(existing_ids)} anciens documents...")
|
| 101 |
+
self.collection.delete(ids=existing_ids)
|
| 102 |
+
|
| 103 |
+
# Générer les embeddings pour toutes les formulations
|
| 104 |
+
formulations: list[str] = [self._prefix_p + self._normalize_text(entry.formulation) for entry in faq_entries]
|
| 105 |
+
logger.info("Génération des embeddings...")
|
| 106 |
+
embeddings = self.model.encode(formulations, show_progress_bar=True,
|
| 107 |
+
convert_to_numpy=True, normalize_embeddings=True)
|
| 108 |
+
|
| 109 |
+
# Ajouter à ChromaDB
|
| 110 |
+
logger.info("Ajout des documents à ChromaDB...")
|
| 111 |
+
self.collection.add(
|
| 112 |
+
ids=[f"faq_{i}" for i in range(len(faq_entries))],
|
| 113 |
+
embeddings=embeddings.tolist(),
|
| 114 |
+
documents=formulations,
|
| 115 |
+
metadatas=[
|
| 116 |
+
{
|
| 117 |
+
"theme": entry.theme,
|
| 118 |
+
"response": entry.response,
|
| 119 |
+
}
|
| 120 |
+
for entry in faq_entries
|
| 121 |
+
],
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")
|
| 125 |
+
|
| 126 |
+
def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
|
| 127 |
+
"""
|
| 128 |
+
Rechercher dans la FAQ la formulation laplus similaire à une question donnée.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
question: Question à chercher
|
| 132 |
+
threshold: Seuil de confiance minimum pour retourner une réponse
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Tuple (formulation, theme, response, similarity_score) ou None si aucun match
|
| 136 |
+
"""
|
| 137 |
+
# Récupérer la question, le seuil et le nombre de résultats à considérer
|
| 138 |
+
question = payload.question
|
| 139 |
+
threshold = payload.threshold if payload.threshold is not None else SIMILARITY_THRESHOLD
|
| 140 |
+
top_k = payload.top_k if payload.top_k is not None else 1
|
| 141 |
+
top_k = max(1, top_k)
|
| 142 |
+
|
| 143 |
+
# Générer l'embedding de la question
|
| 144 |
+
question_embedding = self.model.encode([self._prefix_q + self._normalize_text(question)], convert_to_numpy=True,
|
| 145 |
+
normalize_embeddings=True).tolist()
|
| 146 |
+
|
| 147 |
+
# Rechercher dans ChromaDB avec plusieurs résultats potentiels
|
| 148 |
+
results = self.collection.query(
|
| 149 |
+
query_embeddings=question_embedding,
|
| 150 |
+
n_results=top_k,
|
| 151 |
+
include=["documents", "metadatas", "distances"]
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if not results["ids"] or not results["ids"][0]:
|
| 155 |
+
logger.warning(f"Aucun résultat trouvé pour: {question}")
|
| 156 |
+
return AnswerOutput(
|
| 157 |
+
question=question,
|
| 158 |
+
formulation="unknown",
|
| 159 |
+
answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
|
| 160 |
+
theme="unknown",
|
| 161 |
+
similarity_score=0.0,
|
| 162 |
+
confidence=False,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Extraire le meilleur résultat parmi les candidats retournés par Chroma
|
| 166 |
+
# ChromaDB retourne les produits scalaires, on convertit en similarité cosinus
|
| 167 |
+
best_match = None
|
| 168 |
+
for index in range(len(results["ids"][0])):
|
| 169 |
+
distance = results["distances"][0][index]
|
| 170 |
+
similarity_score: float = 1 - distance
|
| 171 |
+
|
| 172 |
+
metadata = results["metadatas"][0][index]
|
| 173 |
+
formulation = results["documents"][0][index]
|
| 174 |
+
theme = metadata["theme"]
|
| 175 |
+
response = metadata["response"]
|
| 176 |
+
|
| 177 |
+
candidate = {
|
| 178 |
+
"formulation": formulation,
|
| 179 |
+
"theme": theme,
|
| 180 |
+
"response": response,
|
| 181 |
+
"similarity_score": similarity_score,
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if best_match is None or candidate["similarity_score"] > best_match["similarity_score"]:
|
| 185 |
+
best_match = candidate
|
| 186 |
+
|
| 187 |
+
if best_match is None:
|
| 188 |
+
logger.warning(f"Aucun candidat exploitable trouvé pour: {question}")
|
| 189 |
+
return AnswerOutput(
|
| 190 |
+
question=question,
|
| 191 |
+
formulation="unknown",
|
| 192 |
+
answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
|
| 193 |
+
theme="unknown",
|
| 194 |
+
similarity_score=0.0,
|
| 195 |
+
confidence=False,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
similarity_score = round(best_match["similarity_score"], 4)
|
| 199 |
+
|
| 200 |
+
# Vérifier le seuil
|
| 201 |
+
if similarity_score < threshold:
|
| 202 |
+
logger.info(
|
| 203 |
+
f"Score de similarité {similarity_score:.3f} < seuil {threshold}. "
|
| 204 |
+
f"Question: {question}"
|
| 205 |
+
)
|
| 206 |
+
return AnswerOutput(
|
| 207 |
+
question=question,
|
| 208 |
+
formulation=best_match["formulation"],
|
| 209 |
+
answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
|
| 210 |
+
theme=best_match["theme"],
|
| 211 |
+
similarity_score=similarity_score,
|
| 212 |
+
confidence=False,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
logger.info(
|
| 216 |
+
f"Match trouvé - Theme: {best_match['theme']}, Score: {similarity_score:.3f}"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
return AnswerOutput(
|
| 220 |
+
question=question,
|
| 221 |
+
formulation=best_match["formulation"],
|
| 222 |
+
answer=best_match["response"],
|
| 223 |
+
theme=best_match["theme"],
|
| 224 |
+
similarity_score=similarity_score,
|
| 225 |
+
confidence=True,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
def get_collection_size(self) -> int:
|
| 229 |
+
"""
|
| 230 |
+
Obtenir le nombre de documents dans la collection.
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Nombre de documents
|
| 234 |
+
"""
|
| 235 |
+
return self.collection.count()
|
| 236 |
+
|
| 237 |
+
def _normalize_text(self, text: str) -> str:
|
| 238 |
+
# Uniformise les apostrophes et guillemets typographiques
|
| 239 |
+
text = text.translate(self._TRANSLATION_TABLE)
|
| 240 |
+
|
| 241 |
+
# Normalise les caractères unicode équivalents (NFC)
|
| 242 |
+
text = unicodedata.normalize("NFC", text)
|
| 243 |
+
|
| 244 |
+
# Espaces multiples, trim (déjà optimal : split()+join en C)
|
| 245 |
+
text = " ".join(text.split())
|
| 246 |
+
|
| 247 |
+
return text
|
models.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Modèles Pydantic pour les requêtes et réponses API."""
|
|
|
|
| 2 |
from typing import Optional
|
| 3 |
|
| 4 |
from pydantic import BaseModel, Field, ConfigDict
|
|
@@ -11,8 +12,10 @@ class QuestionInput(BaseModel):
|
|
| 11 |
None,
|
| 12 |
ge=0.0,
|
| 13 |
le=1.0,
|
| 14 |
-
description="Seuil de similarité cosinus souhaité entre 0.0 et 1.0.
|
| 15 |
)
|
|
|
|
|
|
|
| 16 |
|
| 17 |
model_config = ConfigDict(
|
| 18 |
json_schema_extra={
|
|
@@ -23,6 +26,14 @@ class QuestionInput(BaseModel):
|
|
| 23 |
}
|
| 24 |
)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
class AnswerOutput(BaseModel):
|
| 28 |
"""Modèle pour une réponse de sortie."""
|
|
@@ -31,7 +42,9 @@ class AnswerOutput(BaseModel):
|
|
| 31 |
answer: str = Field(..., description="Réponse trouvée dans la base FAQ")
|
| 32 |
theme: str = Field(..., description="Thème/catégorie de la réponse")
|
| 33 |
similarity_score: float = Field(..., ge=0.0, le=1.0, description="Score de similarité cosinus [0, 1]")
|
|
|
|
| 34 |
confidence: bool = Field(..., description="True si score >= seuil de confiance, False sinon")
|
|
|
|
| 35 |
|
| 36 |
model_config = ConfigDict(
|
| 37 |
json_schema_extra={
|
|
@@ -41,7 +54,9 @@ class AnswerOutput(BaseModel):
|
|
| 41 |
"answer": "Tu peux soit 'tinscrire, soit commencer ta candidature depuis le bouton « Inscription » du site. Tu renseigneras ton profil, tes compétences et les domaines qui t’intéressent afin que l’équipe puisse étudier ta candidature.",
|
| 42 |
"theme": "comment_rejoindre_guild_open_tech",
|
| 43 |
"similarity_score": 0.889,
|
| 44 |
-
"
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
}
|
| 47 |
)
|
|
|
|
| 1 |
"""Modèles Pydantic pour les requêtes et réponses API."""
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
from typing import Optional
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field, ConfigDict
|
|
|
|
| 12 |
None,
|
| 13 |
ge=0.0,
|
| 14 |
le=1.0,
|
| 15 |
+
description="Seuil de similarité cosinus souhaité entre 0.0 et 1.0.",
|
| 16 |
)
|
| 17 |
+
top_k: Optional[int] = None
|
| 18 |
+
cross_encoder_threshold: Optional[float] = None
|
| 19 |
|
| 20 |
model_config = ConfigDict(
|
| 21 |
json_schema_extra={
|
|
|
|
| 26 |
}
|
| 27 |
)
|
| 28 |
|
| 29 |
+
@dataclass(slots=True)
|
| 30 |
+
class SemanticSearchCandidate:
|
| 31 |
+
"""Candidat issu de la recherche sémantique."""
|
| 32 |
+
formulation: str
|
| 33 |
+
theme: str
|
| 34 |
+
response: str
|
| 35 |
+
similarity_score: float
|
| 36 |
+
cross_encoder_score: float | None = None
|
| 37 |
|
| 38 |
class AnswerOutput(BaseModel):
|
| 39 |
"""Modèle pour une réponse de sortie."""
|
|
|
|
| 42 |
answer: str = Field(..., description="Réponse trouvée dans la base FAQ")
|
| 43 |
theme: str = Field(..., description="Thème/catégorie de la réponse")
|
| 44 |
similarity_score: float = Field(..., ge=0.0, le=1.0, description="Score de similarité cosinus [0, 1]")
|
| 45 |
+
cross_encoder_score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Score du cross-encoder [0, 1]")
|
| 46 |
confidence: bool = Field(..., description="True si score >= seuil de confiance, False sinon")
|
| 47 |
+
list_candidates: Optional[list[SemanticSearchCandidate]] = Field(None, description="Liste des candidats sémantiques")
|
| 48 |
|
| 49 |
model_config = ConfigDict(
|
| 50 |
json_schema_extra={
|
|
|
|
| 54 |
"answer": "Tu peux soit 'tinscrire, soit commencer ta candidature depuis le bouton « Inscription » du site. Tu renseigneras ton profil, tes compétences et les domaines qui t’intéressent afin que l’équipe puisse étudier ta candidature.",
|
| 55 |
"theme": "comment_rejoindre_guild_open_tech",
|
| 56 |
"similarity_score": 0.889,
|
| 57 |
+
"cross_encoder_score": 0.95,
|
| 58 |
+
"confidence": True,
|
| 59 |
+
"list_candidates": []
|
| 60 |
}
|
| 61 |
}
|
| 62 |
)
|