Skip to content

Commit 41dc82a

Browse files
committed
Ajoute un producteur de questions OpenAI
1 parent f12c218 commit 41dc82a

File tree

7 files changed

+449
-0
lines changed

7 files changed

+449
-0
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies = [
88
"docling",
99
"filelock>=3.20.3",
1010
"httpx",
11+
"numpy",
1112
"openai",
1213
"json-repair",
1314
"pandas==2.3.2",
@@ -17,6 +18,8 @@ dependencies = [
1718
"reportlab",
1819
"requests",
1920
"respx",
21+
"scikit-learn",
22+
"sentence-transformers",
2023
"types-reportlab",
2124
"urllib3>=2.5.1",
2225
"aiohttp>=3.13.3",
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any
3+
4+
import numpy as np
5+
from sklearn.cluster import AgglomerativeClustering # type: ignore[import-untyped]
6+
7+
from guides.generateur_question.utils import _decoupe_en_phrases, _charge_encodeur
8+
9+
10+
class CompteurThematiques(ABC):
11+
@abstractmethod
12+
def nombre_topics(self, paragraphe: str) -> int:
13+
raise NotImplementedError
14+
15+
16+
class CompteursThematique(CompteurThematiques):
17+
def __init__(
18+
self,
19+
*,
20+
modele_hf: str = "BAAI/bge-m3",
21+
seuil_distance: float = 0.35,
22+
min_topics: int = 1,
23+
max_topics: int = 10,
24+
min_phrases: int = 2,
25+
encodeur: Any | None = None,
26+
) -> None:
27+
self.modele_hf = modele_hf
28+
self.seuil_distance = seuil_distance
29+
self.min_topics = min_topics
30+
self.max_topics = max_topics
31+
self.min_phrases = min_phrases
32+
self.encodeur = encodeur
33+
34+
def nombre_topics(self, paragraphe: str) -> int:
35+
phrases = _decoupe_en_phrases(paragraphe)
36+
if len(phrases) == 0:
37+
return 0
38+
if len(phrases) < self.min_phrases:
39+
return 1
40+
encodeur = (
41+
self.encodeur
42+
if self.encodeur is not None
43+
else _charge_encodeur(self.modele_hf)
44+
)
45+
vecteurs = encodeur.encode(
46+
phrases,
47+
normalize_embeddings=True,
48+
convert_to_numpy=True,
49+
show_progress_bar=False,
50+
).astype(np.float32)
51+
52+
distances = 1.0 - (vecteurs @ vecteurs.T)
53+
np.fill_diagonal(distances, 0.0)
54+
55+
createur_de_cluster = AgglomerativeClustering(
56+
n_clusters=None,
57+
metric="precomputed",
58+
linkage="average",
59+
distance_threshold=self.seuil_distance,
60+
)
61+
etiquettes = createur_de_cluster.fit_predict(distances)
62+
63+
n = int(len(set(etiquettes.tolist())))
64+
n = max(self.min_topics, min(self.max_topics, n))
65+
return n
66+
67+
68+
def calcule_nombre_questions(
69+
paragraphe: str, compteur_thematiques: CompteurThematiques | None = None
70+
) -> int:
71+
compteur = (
72+
compteur_thematiques
73+
if compteur_thematiques is not None
74+
else CompteursThematique()
75+
)
76+
n_topics = compteur.nombre_topics(paragraphe)
77+
if n_topics == 0:
78+
return 0
79+
return max(3, min(10, n_topics))
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from typing import Any, cast
2+
3+
from openai import OpenAI
4+
from openai.types.chat import ChatCompletion, ChatCompletionMessageParam
5+
6+
from configuration import recupere_configuration
7+
from guides.generateur_question.compteurs_thematique import calcule_nombre_questions
8+
from guides.generateur_question.utils import (
9+
_charge_prompt_systeme,
10+
parse_questions_depuis_contenu,
11+
)
12+
13+
14+
class ProducteurQuestionsOpenAI:
15+
def __init__(
16+
self,
17+
*,
18+
client: Any | None = None,
19+
modele_generation: str | None = None,
20+
temperature: float = 0.0,
21+
) -> None:
22+
configuration = recupere_configuration().albert
23+
self.client = (
24+
client
25+
if client is not None
26+
else OpenAI(base_url=configuration.url, api_key=configuration.cle_api)
27+
)
28+
self.modele_generation = (
29+
modele_generation if modele_generation is not None else configuration.modele
30+
)
31+
self.temperature = temperature
32+
33+
def __call__(self, paragraphe: str) -> list[str]:
34+
n_questions = calcule_nombre_questions(paragraphe)
35+
messages: list[ChatCompletionMessageParam] = [
36+
{"role": "system", "content": _charge_prompt_systeme()},
37+
{
38+
"role": "user",
39+
"content": (
40+
f"Génère exactement {n_questions} questions.\n"
41+
f"Paragraphe :\n{paragraphe}"
42+
),
43+
},
44+
]
45+
completion = self.client.chat.completions.create(
46+
model=self.modele_generation,
47+
messages=messages,
48+
temperature=self.temperature,
49+
stream=False,
50+
n=1,
51+
)
52+
completion = cast(ChatCompletion, completion)
53+
contenu = (completion.choices[0].message.content or "").strip()
54+
return parse_questions_depuis_contenu(contenu)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import json
2+
import re
3+
from functools import lru_cache
4+
from pathlib import Path
5+
6+
import numpy as np
7+
8+
9+
def _charge_prompt_systeme() -> str:
10+
racine_projet = Path(__file__).resolve().parents[3]
11+
chemin_prompt = racine_projet / "tempaltes" / "prompt_generateur_questions.txt"
12+
return chemin_prompt.read_text(encoding="utf-8").strip()
13+
14+
15+
def _extrait_objet_json(texte: str) -> str:
16+
t = (texte or "").strip()
17+
t = re.sub(r"^\s*```(?:json)?\s*", "", t, flags=re.IGNORECASE)
18+
t = re.sub(r"\s*```\s*$", "", t)
19+
t = t.lstrip("\ufeff")
20+
match = re.search(r"\{.*\}", t, flags=re.DOTALL)
21+
if not match:
22+
raise ValueError("Aucun objet JSON détecté dans la sortie du modèle.")
23+
return match.group(0)
24+
25+
26+
def parse_questions_depuis_contenu(contenu: str) -> list[str]:
27+
bloc = _extrait_objet_json(contenu)
28+
obj = json.loads(bloc)
29+
questions = obj.get("questions", [])
30+
if not isinstance(questions, list):
31+
return []
32+
return [q.strip() for q in questions if isinstance(q, str) and q.strip()]
33+
34+
35+
def _compte_mots(texte: str) -> int:
36+
return len(re.findall(r"\b\w+\b", texte, flags=re.UNICODE))
37+
38+
39+
def _decoupe_en_phrases(texte: str) -> list[str]:
40+
texte = re.sub(r"\s+", " ", (texte or "").strip())
41+
if not texte:
42+
return []
43+
phrases = re.split(r"(?<=[.!?])\s+", texte)
44+
return [p.strip() for p in phrases if p.strip()]
45+
46+
47+
def _compte_phrases(texte: str) -> int:
48+
phrases = re.split(r"[.!?]\s+", texte.strip())
49+
phrases = [p for p in phrases if p.strip()]
50+
return max(1, len(phrases))
51+
52+
53+
def _normalise_l2(m: np.ndarray) -> np.ndarray:
54+
normes = np.linalg.norm(m, axis=1, keepdims=True) + 1e-12
55+
return m / normes
56+
57+
58+
@lru_cache(maxsize=4)
59+
def _charge_encodeur(modele_hf: str):
60+
from sentence_transformers import SentenceTransformer
61+
62+
return SentenceTransformer(modele_hf)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Tu es un composant de génération de questions pour un système RAG en cybersécurité (ANSSI).
2+
3+
Mission :
4+
À partir d’un paragraphe, générer EXACTEMENT N questions réalistes qu’un utilisateur pourrait formuler pour retrouver ce paragraphe via recherche sémantique.
5+
6+
Règles de sortie (obligatoires) :
7+
- Retourner UNIQUEMENT un JSON STRICT sur UNE seule ligne, sans texte avant/après, sans Markdown, sans ```fences```.
8+
- Une seule clé autorisée : "questions".
9+
- Format exact : {"questions":["...","..."]}
10+
11+
Contraintes de contenu :
12+
1) Langue : français.
13+
2) Chaque élément de "questions" est une UNIQUE phrase interrogative et se termine par "?".
14+
3) Répondabilité (answerability) : chaque question doit être répondable uniquement à partir du paragraphe.
15+
- Interdit : exiger une source externe, une interprétation juridique non présente, ou un contexte absent.
16+
4) Autoportance : aucune question ne doit dépendre d’un contexte externe.
17+
- Interdit : pronoms/référents non résolus ("ça", "cela", "ce cas", "cette méthode", "ils") sans nom explicite.
18+
5) Un seul axe par question :
19+
- Interdit : combiner deux thèmes indépendants dans une même question (ex : "périmètre ET coopération").
20+
- Si deux thèmes apparaissent, produire deux questions distinctes.
21+
6) Non-duplication : pas de doublons ni de paraphrases quasi identiques.
22+
23+
Couverture attendue (diversité) :
24+
- La liste doit couvrir des angles différents parmi :
25+
- définition / explication
26+
- mécanisme / fonctionnement
27+
- risques / menaces / attaques
28+
- limites / contournements / échecs
29+
- bonnes pratiques / recommandations
30+
- causes / conséquences
31+
- comparaison / alternatives
32+
33+
Optimisation retrieval (longueur et densité de signal) :
34+
7) Répartition :
35+
- 30–40% de "requêtes courtes" (6 à 10 mots) de type moteur de recherche, terminées par "?".
36+
- Les autres questions sont concises : 8 à 16 mots maximum.
37+
8) Requêtes courtes : elles doivent rester interprétables.
38+
- Elles doivent contenir soit (a) un verbe, soit (b) un noyau d’intention explicite
39+
(ex : "entités concernées", "changements majeurs", "objectif", "rôle", "périmètre", "exigences").
40+
- Interdit : suites nominales vagues sans intention (ex : "Rôle X dans Y ?" si cela devient ambigu).
41+
9) Élagage :
42+
- Supprimer les détails non discriminants qui dégradent le retrieval : dates, jugements, cadrages vagues,
43+
formulations verbeuses ("est-il considéré comme", "en matière de", "à l’échelle ...").
44+
- Ne conserver que : sujet + intention + 1 à 3 termes/entités clés présents dans le paragraphe.
45+
10) Robustesse lexicale :
46+
- Conserver les termes techniques du paragraphe.
47+
- Ajouter au plus 1 synonyme utile par question quand pertinent (ex : "coffre-fort de mots de passe" / "gestionnaire de mots de passe").
48+
- Conserver les acronymes, et développer uniquement si le développement est explicitement présent dans le paragraphe.
49+
50+
Mise en avant des recommandations ANSSI :
51+
11) Si le paragraphe contient une mention de recommandation "R" suivie d’un ou plusieurs chiffres (ex : "R1", "R32", "R33", "R34") :
52+
- Générer au moins UNE question dédiée par recommandation détectée.
53+
- La question doit citer explicitement la recommandation (ex : "Que dit la recommandation R33 sur ... ?").
54+
- Interdit : inventer le contenu complet de la recommandation si le paragraphe ne le détaille pas.
55+
56+
Nettoyage obligatoire (anti-notes, anti-citations, anti-marquage éditorial) :
57+
12) Interdire et supprimer dans les questions :
58+
- toute référence bibliographique ou note : tout motif entre crochets [ ... ] (ex : [14], [1], [12–14]).
59+
- tout astérisque "*" et tout texte entre astérisques (ex : *obligatoire*).
60+
- "cf.", "voir", "référence", "guide", "article", ou toute mention de source externe.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from types import SimpleNamespace
2+
3+
import pytest
4+
5+
from guides.generateur_question.producteur_questions import (
6+
ProducteurQuestionsOpenAI,
7+
_charge_prompt_systeme,
8+
calcule_nombre_questions,
9+
parse_questions_depuis_contenu,
10+
)
11+
from guides.generateur_question.compteurs_thematique import (
12+
CompteurThematiques,
13+
CompteursThematique,
14+
)
15+
from guides.generateur_question.utils import (
16+
_extrait_objet_json,
17+
_compte_mots,
18+
_compte_phrases,
19+
)
20+
21+
22+
class FauxClientOpenAI:
23+
def __init__(self, contenu: str):
24+
self.contenu = contenu
25+
self.appels: list[dict] = []
26+
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._cree))
27+
28+
def _cree(self, **kwargs):
29+
self.appels.append(kwargs)
30+
return SimpleNamespace(
31+
choices=[SimpleNamespace(message=SimpleNamespace(content=self.contenu))]
32+
)
33+
34+
35+
def test_producteur_questions_openai_retourne_les_questions_json():
36+
contenu = """```json
37+
{"questions": ["Question 1 ?", "Question 2 ?"]}
38+
```"""
39+
client = FauxClientOpenAI(contenu)
40+
producteur = ProducteurQuestionsOpenAI(
41+
client=client,
42+
modele_generation="modele-test",
43+
)
44+
45+
paragraphe = "Un paragraphe test simple."
46+
resultats = producteur(paragraphe)
47+
48+
assert resultats == ["Question 1 ?", "Question 2 ?"]
49+
assert len(client.appels) == 1
50+
assert client.appels[0]["model"] == "modele-test"
51+
52+
53+
def test_charge_prompt_systeme_charge_le_contenu_du_fichier():
54+
prompt = _charge_prompt_systeme()
55+
assert "Tu es un composant de génération de questions" in prompt
56+
57+
58+
def test_extrait_objet_json_retire_les_fences():
59+
contenu = '```json\n{"questions": ["Q1 ?"]}\n```'
60+
61+
extrait = _extrait_objet_json(contenu)
62+
63+
assert extrait == '{"questions": ["Q1 ?"]}'
64+
65+
66+
def test_parse_questions_depuis_contenu_extrait_les_questions():
67+
contenu = '```json\n{"questions": ["Q1 ?", "Q2 ?"]}\n```'
68+
69+
resultats = parse_questions_depuis_contenu(contenu)
70+
71+
assert resultats == ["Q1 ?", "Q2 ?"]
72+
73+
74+
def test_compte_mots_compte_les_mots():
75+
assert _compte_mots("Un texte simple.") == 3
76+
77+
78+
def test_compte_phrases_compte__une_phrase():
79+
assert _compte_phrases("Une première phrase.") == 1
80+
81+
82+
def test_compte_phrases_compte_au_moins_une_phrase():
83+
assert _compte_phrases("") == 1
84+
85+
86+
def test_compte_phrases_compte_les_phrases():
87+
assert _compte_phrases("Phrase une. Phrase deux ? Phrase trois !") == 3
88+
89+
90+
class CompteurThematiquesDeTest(CompteurThematiques):
91+
def __init__(self, topics: int):
92+
self.topics = topics
93+
94+
def nombre_topics(self, _: str) -> int:
95+
return self.topics
96+
97+
98+
@pytest.mark.parametrize(
99+
("topics", "attendu"),
100+
[
101+
(0, 0),
102+
(1, 3),
103+
(2, 3),
104+
(5, 5),
105+
(10, 10),
106+
(12, 10),
107+
],
108+
)
109+
def test_calcule_nombre_questions_est_borne(topics: int, attendu: int):
110+
compteur = CompteurThematiquesDeTest(topics)
111+
resultat = calcule_nombre_questions("peu importe", compteur_thematiques=compteur)
112+
assert resultat == attendu
113+
114+
115+
def test_compteur_topics_retourne_0_si_paragraphe_vide():
116+
compteur = CompteursThematique()
117+
assert compteur.nombre_topics("") == 0
118+
119+
120+
def test_compteur_topics_retourne_1_si_pas_assez_de_phrases():
121+
compteur = CompteursThematique()
122+
assert compteur.nombre_topics("Une seule phrase.") == 1

0 commit comments

Comments
 (0)