Whisler commited on
Commit
1fbca63
·
1 Parent(s): 164afda

Ajout app.py et requirements.txt

Browse files
Files changed (4) hide show
  1. .env +3 -0
  2. README.md +11 -16
  3. app.py +764 -0
  4. requirements.txt +4 -1
.env ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ MISTRAL_API_KEY=MKsv7UiQIa9cZiK1wTBj8fJxkdGQmtp0
2
+ QDRANT_URL=https://60345ec0-33a0-4d58-8c6b-0ee3836eab9a.europe-west3-0.gcp.cloud.qdrant.io:6333
3
+ QDRANT_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0._j3BGXxD1_81_iYQNMY_MNNMMfAZaiA39IW_EirgnQA
README.md CHANGED
@@ -1,19 +1,14 @@
1
- ---
2
- title: Parlement Rag
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Générateur de projets de réponse à des questions écrites
12
- ---
13
 
14
- # Welcome to Streamlit!
 
 
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
1
+ # CamemBERT finetuné progressif 🚀
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ Ce modèle est une version finetunée de [CamemBERT](https://huggingface.co/camembert-base)
4
+ dans le cadre de mon projet CS50.
5
+ Il est conçu pour générer des embeddings adaptés à des tâches de compréhension de texte en français.
6
 
7
+ ## 📦 Utilisation
8
 
9
+ ```python
10
+ from sentence_transformers import SentenceTransformer
11
+
12
+ model = SentenceTransformer("Whisler/camembert_finetuned_progressive")
13
+ embeddings = model.encode(["Exemple de phrase en français"])
14
+ print(embeddings)
app.py ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import hashlib
3
+ import yaml
4
+ import requests
5
+ import pyperclip
6
+ import os
7
+ from yaml.loader import SafeLoader
8
+ from docx import Document
9
+ from dotenv import load_dotenv
10
+
11
+ import streamlit as st
12
+ st.set_page_config(page_title="Generateur", page_icon="🗳️")
13
+
14
+ # --- 1. Chargement des variables d'environnement ---
15
+ load_dotenv()
16
+ QDRANT_URL = os.getenv("QDRANT_URL", "").strip()
17
+ QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", "").strip()
18
+ MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY", "").strip()
19
+ QDRANT_COLLECTION = "QuestionParlementaire"
20
+
21
+ if not QDRANT_URL.startswith("https://"):
22
+ raise RuntimeError("❌ QDRANT_URL doit commencer par https://")
23
+ if not QDRANT_API_KEY:
24
+ raise RuntimeError("❌ QDRANT_API_KEY manquant. Vérifiez votre fichier .env")
25
+ if not MISTRAL_API_KEY:
26
+ raise RuntimeError("❌ MISTRAL_API_KEY manquant. Vérifiez votre fichier .env")
27
+
28
+ print("QDRANT_URL:", QDRANT_URL)
29
+ print("QDRANT_API_KEY (début):", QDRANT_API_KEY[:10], "...")
30
+
31
+
32
+ from sentence_transformers import SentenceTransformer
33
+ import streamlit as st
34
+
35
+ # --- 3. Chargement du modèle ---
36
+ EMBEDDING_MODEL_PATH = "./models/camembert_finetuned_progressive"
37
+
38
+ @st.cache_resource
39
+ def load_embedding_model():
40
+ try:
41
+ model = SentenceTransformer(EMBEDDING_MODEL_PATH)
42
+ test_embedding = model.encode("Test de chargement du modèle.")
43
+ VECTOR_SIZE = len(test_embedding)
44
+ print("✅ Modèle CamemBERT chargé. Dimension:", VECTOR_SIZE)
45
+ return model
46
+ except Exception as e:
47
+ print("❌ Erreur de chargement du modèle:", str(e))
48
+ raise
49
+
50
+ embedding_model = load_embedding_model()
51
+
52
+
53
+
54
+
55
+
56
+ # --- 4. Connexion à Qdrant ---
57
+ try:
58
+ qdrant_client = QdrantClient(
59
+ url=QDRANT_URL,
60
+ api_key=QDRANT_API_KEY,
61
+ timeout=10.0,
62
+ check_compatibility=False
63
+ )
64
+ collections = qdrant_client.get_collections()
65
+ print(f"✅ Connexion réussie. Collections disponibles: {[c.name for c in collections.collections]}")
66
+ except Exception as e:
67
+ print(f"❌ Erreur de connexion à Qdrant: {e}")
68
+ raise
69
+
70
+
71
+
72
+
73
+ # --- 6. Fonction de recherche ---
74
+ def search_similar_questions(query: str, legislature: str = None, rubrique: str = None, limit: int = 5):
75
+ collections = qdrant_client.get_collections()
76
+ if QDRANT_COLLECTION not in [c.name for c in collections.collections]:
77
+ raise HTTPException(
78
+ status_code=500,
79
+ detail=f"La collection '{QDRANT_COLLECTION}' n'existe pas encore. Veuillez l'initialiser."
80
+ )
81
+ embedding = embedding_model.encode(query).tolist()
82
+ query_filter = None
83
+ conditions = []
84
+ if legislature:
85
+ conditions.append(models.FieldCondition(key="legislature", match=models.MatchText(text=legislature)))
86
+ if rubrique:
87
+ conditions.append(models.FieldCondition(key="rubrique", match=models.MatchText(text=rubrique)))
88
+ if conditions:
89
+ query_filter = models.Filter(must=conditions)
90
+ results = qdrant_client.search(
91
+ collection_name=QDRANT_COLLECTION,
92
+ query_vector=embedding,
93
+ query_filter=query_filter,
94
+ limit=limit,
95
+ with_payload=True
96
+ )
97
+ if not results:
98
+ raise HTTPException(status_code=404, detail=f"Aucun résultat trouvé dans '{QDRANT_COLLECTION}'.")
99
+ return [result.payload for result in results]
100
+
101
+ def search_documents(query: str = None, legislature: str = None, rubrique: str = None,
102
+ ministere: str = None, limit: int = 10, offset: int = 0):
103
+ """Recherche des documents dans Qdrant avec filtres"""
104
+ conditions = []
105
+ if legislature:
106
+ conditions.append(models.FieldCondition(key="legislature", match=models.MatchText(text=legislature)))
107
+ if rubrique:
108
+ conditions.append(models.FieldCondition(key="rubrique", match=models.MatchText(text=rubrique)))
109
+ if ministere:
110
+ conditions.append(models.FieldCondition(key="ministeres_attribues", match=models.MatchAny(any=[ministere])))
111
+
112
+ query_filter = models.Filter(must=conditions) if conditions else None
113
+
114
+ # Recherche par similarité ou scroll
115
+ if query:
116
+ embedding = embedding_model.encode(query).tolist()
117
+ results = qdrant_client.search(
118
+ collection_name=QDRANT_COLLECTION,
119
+ query_vector=embedding,
120
+ query_filter=query_filter,
121
+ limit=limit,
122
+ offset=offset,
123
+ with_payload=True
124
+ )
125
+ else:
126
+ results, _ = qdrant_client.scroll(
127
+ collection_name=QDRANT_COLLECTION,
128
+ query_filter=query_filter,
129
+ limit=limit,
130
+ offset=offset,
131
+ with_payload=True
132
+ )
133
+
134
+ # Formatage des résultats
135
+ documents = []
136
+ for result in results:
137
+ payload = result.payload
138
+ documents.append(DocumentInfo(
139
+ uid=payload.get("uid", ""),
140
+ date=payload.get("date_reponse", payload.get("date_question", "Inconnue")),
141
+ chambre=(
142
+ "Assemblée nationale" if str(payload.get("uid", "")).startswith('QAN')
143
+ else "Sénat" if str(payload.get("uid", "")).isdigit() or isinstance(payload.get("uid"), (int, float))
144
+ else "Inconnue"
145
+ ),
146
+ question=payload.get("question", "")[:200] + "..." if len(payload.get("question", "")) > 200 else payload.get("question", ""),
147
+ themes=payload.get("rubrique", "").split(";") if payload.get("rubrique") else [],
148
+ ministeres=payload.get("ministeres_attribues", [])
149
+ ))
150
+
151
+ return {
152
+ "documents": documents,
153
+ "total": len(results) if query else qdrant_client.get_collection(QDRANT_COLLECTION).points_count,
154
+ "limit": limit,
155
+ "offset": offset
156
+ }
157
+
158
+
159
+
160
+
161
+
162
+ # --- 7. Génération de la réponse ---
163
+ @app.post("/ask", response_model=RAGResponse)
164
+ async def generate_response(request: QuestionRequest):
165
+ try:
166
+ # Recherche des questions similaires (5 résultats)
167
+ similar_results = search_similar_questions(
168
+ request.question,
169
+ legislature=request.legislature,
170
+ rubrique=request.rubrique,
171
+ limit=5
172
+ )
173
+
174
+ # MODIFIÉ : Création d'une liste de SimilarQuestion avec le numéro de question
175
+ similar_questions = [
176
+ SimilarQuestion(
177
+ date=result.get("date_reponse", "Inconnue"),
178
+ chambre=(
179
+ f"Assemblée nationale ({result.get('uid', '')})"
180
+ if isinstance(result.get("uid"), str) and result.get("uid", "").startswith('QAN') else
181
+ f"Sénat ({result.get('uid', '')})"
182
+ if isinstance(result.get("uid"), (int, float)) or (isinstance(result.get("uid"), str) and result.get("uid", "").isdigit()) else
183
+ "Inconnue"
184
+ ),
185
+ question=result.get("question", ""),
186
+ reponse=result.get("reponse", "")
187
+ )
188
+ for result in similar_results
189
+ if "question" in result
190
+ ]
191
+
192
+ # Recherche des contextes pour la réponse (3 résultats)
193
+ rag_results = search_similar_questions(
194
+ request.question,
195
+ legislature=request.legislature,
196
+ rubrique=request.rubrique,
197
+ limit=3
198
+ )
199
+ contexts = []
200
+ textes_juridiques = set()
201
+ for result in rag_results:
202
+ contexts.append(result.get("reponse", ""))
203
+ if "textes_juridiques" in result:
204
+ for texte in result["textes_juridiques"]:
205
+ texte_str = f"{texte.get('code', '')} {texte.get('article', '')}".strip()
206
+ if texte_str:
207
+ textes_juridiques.add(texte_str)
208
+ context_str = "\n\n".join([f"Contexte {i+1}: {ctx}" for i, ctx in enumerate(contexts)])
209
+ textes_juridiques_str = (
210
+ "\n".join(f"- {texte}" for texte in textes_juridiques)
211
+ if textes_juridiques
212
+ else "Aucun texte juridique spécifique"
213
+ )
214
+
215
+ # Définition du prompt selon le type de requête (analyse ou question parlementaire)
216
+ def build_prompt(request: QuestionRequest, context_str: str, textes_juridiques_str: str):
217
+ """Construit le prompt avec toutes les options"""
218
+
219
+ # Mapping du type de réponse attendue
220
+ orientation_mapping = {
221
+ "Répondre de façon neutre": "",
222
+ "Répondre négativement aux propositions du parlementaire":
223
+ "Répondez de manière polie mais ferme en rejetant les propositions du parlementaire. "
224
+ "Mettez en avant les contraintes légales, budgétaires ou politiques qui empêchent "
225
+ "de donner suite à cette demande. Utilisez des formulations comme: "
226
+ "'comme indiqué précédemment', 'conformément à la réglementation en vigueur', "
227
+ "'les contraintes budgétaires ne permettent pas'.",
228
+
229
+ "Répondre positivement aux propositions du parlementaire":
230
+ "Répondez de manière positive. Utilisez des formulations comme: "
231
+ "'le Gouvernement salue cette initiative', 'cette proposition va dans le sens de', "
232
+ "'nous partageons pleinement cette préoccupation', 'nous travaillerons avec cette assemblée pour' "
233
+ "'nous travaillerons avec vous pour', 'Mettez en avant les aspects "
234
+ "innovants et l'engagement du gouvernement.",
235
+
236
+ "Répondre de manière technique et détaillée":
237
+ "Fournissez une réponse extrêmement détaillée et technique. "
238
+ "Incluez des références précises aux articles de loi, des données chiffrées, "
239
+ "et des explications sur les procédures administratives. Utilisez un vocabulaire "
240
+ "spécialisé et des termes juridiques précis."
241
+
242
+ }
243
+
244
+ # Instructions personnalisées
245
+ custom_instructions_part = ""
246
+ if request.custom_instructions:
247
+ custom_instructions_part = f"""
248
+ **Instructions spécifiques supplémentaires:**
249
+ {request.custom_instructions}
250
+ """
251
+ # Construction du prompt selon le mode
252
+ if request.mode == "analyse":
253
+ prompt = f"""
254
+ [INST]
255
+ Vous êtes un assistant parlementaire expert. Fournissez une analyse synthétique du sujet,
256
+ en mettant en avant les points clés et les références juridiques pertinentes.
257
+
258
+ **Question:** {request.question}
259
+
260
+ **Contexte:**
261
+ {context_str}
262
+
263
+ **Textes juridiques applicables:**
264
+ {textes_juridiques_str}
265
+
266
+ **Consignes:**
267
+ - Détail juridique: {request.detail_juridique}/5
268
+ - Longueur: {request.longueur}
269
+ - Style: concis, analytique, pédagogique.
270
+ [/INST]
271
+ """
272
+
273
+ else: # mode == "parlementaire"
274
+ prompt = f"""
275
+ [INST]
276
+ {orientation_mapping.get(request.response_orientation, "")}
277
+
278
+ {custom_instructions_part}
279
+
280
+ Vous êtes attaché au ministère. Rédigez une réponse officielle à une question écrite parlementaire
281
+ dans le style des réponses publiées au Journal Officiel.
282
+
283
+ **Question:** {request.question}
284
+
285
+ **Contexte:**
286
+ {context_str}
287
+
288
+ **Textes juridiques applicables:**
289
+ {textes_juridiques_str}
290
+
291
+ **Consignes:**
292
+ - Détail juridique: {request.detail_juridique}/5
293
+ - Commencez la réponse en rappelant la préoccupation du député.
294
+ - Poursuivez avec les chiffres, les références juridiques et les mesures en cours de mise en oeuvre.
295
+ - Concluez en réaffirmant l'engagement du Gouvernement.
296
+ - Ne pas mélanger le sujet à d'autres sujets trop éloignés dans la conclusion.
297
+ - Utilisez un style administratif et formel, comme dans les réponses ministérielles.
298
+ - La réponse doit être rédigée en prose continue, sans titres, sans puces, sans numérotation.
299
+ - Utilisez uniquement des paragraphes rédigés, comme dans les réponses ministérielles publiées au Journal Officiel.
300
+ - Si vous avez plusieurs éléments à présenter, intégrez-les dans des phrases complètes reliées par des connecteurs ("par ailleurs", "en outre", "de plus").
301
+ - Ne pas mettre de formule de politesse à la fin.
302
+ - **Contrainte de longueur absolue** : La réponse ne doit pas dépasser {request.longueur}.
303
+ - Toute réponse plus longue sera rejetée.
304
+ - Si le sujet est trop complexe pour tenir dans cette limite, concentrez-vous sur les points les plus importants.
305
+ - Toute réponse qui se termine par une phrase tronquée est incorrecte.
306
+ - Toute réponse qui contient des listes ou des titres est incorrecte.
307
+ [/INST]
308
+ """
309
+ return prompt
310
+
311
+ # --- Adapter max_tokens dynamiquement ---
312
+ if request.longueur.startswith("Courte"):
313
+ max_tokens = 500 # marge pour ~300 mots
314
+ elif request.longueur.startswith("Moyenne"):
315
+ max_tokens = 1000 # marge pour ~500 mots
316
+ elif request.longueur.startswith("Longue"):
317
+ max_tokens = 2200 # marge pour ~1000 mots
318
+ else:
319
+ max_tokens = 1000 # valeur par défaut
320
+
321
+ # Construire le prompt
322
+ prompt = build_prompt(request, context_str, textes_juridiques_str)
323
+
324
+ # 1ère interrogation Mistral
325
+ mistral_api_url = "https://api.mistral.ai/v1/chat/completions"
326
+ headers = {"Authorization": f"Bearer {MISTRAL_API_KEY}", "Content-Type": "application/json"}
327
+
328
+ payload = {
329
+ "model": "mistral-large-latest",
330
+ "messages": [{"role": "user", "content": prompt}],
331
+ "temperature": 0.3,
332
+ "max_tokens": max_tokens
333
+ }
334
+
335
+ response = requests.post(mistral_api_url, headers=headers, json=payload, timeout=90)
336
+ response.raise_for_status()
337
+ data = response.json()
338
+ mistral_response = data["choices"][0]["message"]["content"]
339
+
340
+ # Vérification du tronquage
341
+ is_truncated = data["choices"][0].get("finish_reason") == "length"
342
+
343
+ if is_truncated:
344
+ # Extraction de la dernière phrase incomplète
345
+ last_period = mistral_response.rfind('.')
346
+ if last_period > 0 and last_period < len(mistral_response) - 100:
347
+ incomplete_part = mistral_response[last_period+1:]
348
+ else:
349
+ incomplete_part = mistral_response[-100:]
350
+
351
+ # Prompt de complétion optimisé
352
+ completion_prompt = f"""
353
+ [INST]
354
+ Complétez cette réponse parlementaire de manière naturelle et formelle:
355
+
356
+ Contexte: {request.question[:200]}...
357
+ Dernière partie à compléter: "{incomplete_part}"
358
+
359
+ Consignes:
360
+ - Terminez la phrase/paragraphe en cours de manière cohérente.
361
+ - Ajoutez une conclusion sur le thème de la question en 1-2 phrases maximum.
362
+ - Utilisez un style administratif et formel, comme dans les réponses ministérielles.
363
+ - Respectez strictement la limite de 400 caractères.
364
+ - Tout complément de réponse plus long sera rejeté.
365
+ - Concluez en réaffirmant l'engagement du Gouvernement.
366
+ - La réponse doit être rédigée en prose continue, sans titres, sans puces, sans numérotation.
367
+ - Utilisez uniquement des paragraphes rédigés, comme dans les réponses ministérielles publiées au Journal Officiel.
368
+ - Si vous avez plusieurs éléments à présenter, intégrez-les dans des phrases complètes reliées par des connecteurs ("par ailleurs", "en outre", "de plus").
369
+ - Ne pas mettre de formule de politesse à la fin.
370
+ - Tout complément de réponse qui se termine par une phrase tronquée est incorrecte.
371
+ - Toute complément de réponse qui contient des listes ou des titres est incorrecte.
372
+
373
+ [/INST]
374
+ """
375
+
376
+ completion_payload = {
377
+ "model": "mistral-large-latest",
378
+ "messages": [{"role": "user", "content": completion_prompt}],
379
+ "temperature": 0.1, # Plus déterministe
380
+ "max_tokens": 60
381
+ }
382
+
383
+ try:
384
+ # Deuxième et dernier appel à Mistral
385
+ completion_response = requests.post(
386
+ mistral_api_url,
387
+ headers=headers,
388
+ json=completion_payload,
389
+ timeout=30
390
+ )
391
+ completion_response.raise_for_status()
392
+ completion = completion_response.json()["choices"][0]["message"]["content"]
393
+
394
+ # Fusion intelligente
395
+ if mistral_response.endswith("..."):
396
+ mistral_response = mistral_response[:-3] + completion
397
+ elif mistral_response.endswith(" "):
398
+ mistral_response += completion
399
+ else:
400
+ mistral_response += " " + completion
401
+
402
+ # Vérification finale de la ponctuation
403
+ if not mistral_response.endswith(('.', '!', '?')):
404
+ mistral_response += "."
405
+
406
+ except Exception as e:
407
+ # Fallback en cas d'échec de la complétion
408
+ mistral_response += " Je vous prie d'agréer, Monsieur le Député, l'expression de ma considération distinguée."
409
+
410
+ return {
411
+ "question": request.question,
412
+ "context": contexts,
413
+ "response": mistral_response,
414
+ "sources": list(textes_juridiques),
415
+ "similar_questions": similar_questions,
416
+ "metadata": {
417
+ "legislature": request.legislature,
418
+ "rubrique": request.rubrique,
419
+ "model_used": "mistral-large-latest",
420
+ "was_completed": is_truncated,
421
+ "final_length": len(mistral_response)
422
+ }
423
+ }
424
+
425
+ except Exception as e:
426
+ raise HTTPException(status_code=500, detail=f"Erreur backend: {str(e)}")
427
+
428
+ # --- NOUVEL ENDPOINT SIMPLIFIÉ POUR LISTER LES DOCUMENTS ---
429
+
430
+ @app.get("/simple-documents-list")
431
+ async def get_simple_documents_list():
432
+ """Retourne juste les deux catégories de documents - VERSION STATIQUE"""
433
+ return {
434
+ "documents": [
435
+ {
436
+ "type": "Questions écrites (QE) de l'Assemblée nationale",
437
+ "periode": "2017-2025",
438
+ "description": "Questions ayant obtenu une réponse ministérielle (avant le 1er novembre 2025)."
439
+ },
440
+ {
441
+ "type": "Questions écrites (QE) du Sénat",
442
+ "periode": "2017-2025",
443
+ "description": "Collection complète des questions écrites ayant obtenu une réponse ministérielle (avant le 1er novembre 2025)."
444
+ }
445
+ ]
446
+ }
447
+
448
+ # st.set_page_config(page_title="Générateur de réponses aux questions écrites parlementaires", page_icon="🏛️", layout="wide")
449
+
450
+ # --- 1. Configuration ---
451
+ with open('config.yaml') as file:
452
+ config = yaml.load(file, Loader=SafeLoader)
453
+
454
+ # --- 2. Authentification ---
455
+ if 'authentication_status' not in st.session_state:
456
+ st.session_state.authentication_status = None
457
+
458
+ def check_password():
459
+ if st.session_state["username"] in config['credentials']['usernames']:
460
+ stored_password = config['credentials']['usernames'][st.session_state["username"]]["password"]
461
+ if hashlib.sha256(st.session_state["password"].encode()).hexdigest() == stored_password:
462
+ st.session_state["authentication_status"] = True
463
+ st.session_state["name"] = config['credentials']['usernames'][st.session_state["username"]]["name"]
464
+ return
465
+ st.session_state["authentication_status"] = False
466
+
467
+ # --- 3. Connexion ou contenu principal ---
468
+ if st.session_state.authentication_status is not True:
469
+ # Masquer la sidebar avant connexion
470
+ st.markdown("""
471
+ <style>
472
+ [data-testid="stSidebar"] {display: none;}
473
+ /* Centrer le titre */
474
+ .auth-title {text-align: center; margin-top: 0.5rem;}
475
+ /* Centrer le paragraphe d'intro */
476
+ .auth-intro {text-align: center; color: #5c5c5c;}
477
+ </style>
478
+ """, unsafe_allow_html=True)
479
+
480
+ # --- Page d'authentification ---
481
+ st.markdown('<h1 class="auth-title">🔐 Authentification requise</h1>', unsafe_allow_html=True)
482
+ st.markdown('<p class="auth-intro">Veuillez entrer vos identifiants pour accéder au générateur de réponses aux questions écrites.</p>', unsafe_allow_html=True)
483
+
484
+ # Colonnes pour réduire la largeur et centrer les champs
485
+ # Ajuste les ratios pour obtenir la largeur souhaitée (ici ~25% de la page)
486
+ left, center, right = st.columns([3, 2, 3])
487
+ with center:
488
+ st.text_input("Nom d'utilisateur", key="username")
489
+ st.text_input("Mot de passe", type="password", key="password")
490
+ if st.button("Se connecter"):
491
+ check_password()
492
+ st.rerun()
493
+
494
+ if st.session_state.authentication_status is False:
495
+ st.error("Identifiants incorrects. Veuillez réessayer.")
496
+
497
+ else:
498
+ # Réafficher la sidebar après connexion
499
+ st.markdown("""
500
+ <style>
501
+ [data-testid="stSidebar"] {display: block;}
502
+ </style>
503
+ """, unsafe_allow_html=True)
504
+
505
+ # --- Tout le reste du contenu (après connexion) ---
506
+
507
+ # --- 4. Section Consulter les documents RAG ---
508
+
509
+ # --- 6. Configuration de la page et CSS ---
510
+ st.markdown("""
511
+ <style>
512
+ /* Votre CSS existant */
513
+ .stApp { background-color: #f8f9fa; }
514
+ .stTabs [data-baseweb="tab-list"] { gap: 0; background-color: #e9ecef; border-radius: 6px 6px 0 0; padding: 4px; }
515
+ .stTabs [data-baseweb="tab"] { height: 36px; white-space: pre-wrap; background-color: #f8f9fa; border: none; border-radius: 4px 4px 0 0; padding: 0 12px; }
516
+ .stTabs [aria-selected="true"] { background-color: #ffffff; font-weight: bold; color: #3d3d3d; }
517
+ .stButton>button { background-color: #4a8bfc; color: white; border: none; border-radius: 4px; padding: 8px 16px; font-weight: 500; }
518
+ .stButton>button:hover { background-color: #3a7bfc; }
519
+ .stExpander { background-color: #ffffff; border: 1px solid #e9ecef; border-radius: 6px; margin-bottom: 8px; }
520
+ .stTextArea textarea { font-family: 'Segoe UI', sans-serif; font-size: 16px; line-height: 1.5; }
521
+ .stAlert { border-radius: 6px; }
522
+ .source-text { font-family: monospace; font-size: 14px; background-color: #f8f9fa; padding: 8px; border-radius: 4px; border-left: 3px solid #4a8bfc; }
523
+ .response-text { font-family: 'Segoe UI', sans-serif; font-size: 16px; line-height: 1.6; white-space: pre-wrap; background-color: white; padding: 16px; border-radius: 6px; border: 1px solid #e9ecef; }
524
+ </style>
525
+ """, unsafe_allow_html=True)
526
+
527
+ st.title("🏛️ Générateur de réponses aux questions écrites parlementaires")
528
+ st.markdown("""
529
+ Application (version Beta) générant des projets de réponse aux questions parlementaires,
530
+ appuyée sur une base documentaire (embedding avec **camemBERT**) et le modèle **Mistral Large**.
531
+ """)
532
+
533
+ # --- Sidebar unifiée ---
534
+ with st.sidebar:
535
+ # Bouton Déconnexion
536
+ if st.button('Déconnexion', key="logout"):
537
+ st.session_state.authentication_status = None
538
+ st.rerun()
539
+
540
+ # Message de bienvenue
541
+ st.write(f'Bienvenue *{st.session_state["name"]}*')
542
+
543
+ # Séparateur
544
+ st.markdown("---")
545
+
546
+ # Paramètres de mode
547
+ mode = st.radio(
548
+ "Type de réponse souhaitée",
549
+ ["Réponse parlementaire", "Analyse synthétique"],
550
+ index=0
551
+ )
552
+
553
+ # Ajoutez une séparation visuelle
554
+ st.sidebar.markdown("---") # ← AJOUTEZ CETTE LIGNE
555
+
556
+ # --- 8. Zone de saisie (hauteur x2) ---
557
+ question = st.text_area(
558
+ "Posez votre question parlementaire:",
559
+ height=300, # ← ICI : hauteur doublée
560
+ placeholder="Exemple: Quelles sont les dernières mesures contre la précarité énergétique ?",
561
+ key="question_input"
562
+ )
563
+
564
+ # --- NOUVEAU : Paramètres de réponse (entre question et bouton) ---
565
+ st.markdown("### Paramètres de réponse")
566
+
567
+ # Organisation en 3 colonnes
568
+ col1, col2, col3 = st.columns(3)
569
+
570
+ with col1:
571
+ # Orientation de la réponse
572
+ response_orientation_options = [
573
+ "Répondre de façon neutre",
574
+ "Répondre négativement aux propositions du parlementaire",
575
+ "Répondre positivement aux propositions du parlementaire",
576
+ "Répondre de manière technique et détaillée"
577
+ ]
578
+ selected_orientation = st.selectbox(
579
+ "Orientation de la réponse",
580
+ response_orientation_options,
581
+ index=0
582
+ )
583
+
584
+ with col2:
585
+ # Longueur de la réponse (déplacée ici)
586
+ longueur = st.selectbox(
587
+ "Longueur de la réponse",
588
+ ["Courte (300 mots)", "Moyenne (500 mots)", "Longue (1000 mots)"],
589
+ index=1
590
+ )
591
+
592
+ with col3:
593
+ # Niveau de détail juridique
594
+ detail_juridique = st.slider(
595
+ "Niveau de détail juridique (1 = bas, 5 = élevé)",
596
+ min_value=1,
597
+ max_value=5,
598
+ value=3
599
+ )
600
+
601
+ # Instructions supplémentaires (pleine largeur)
602
+ # Limite de longueur (ex: 300 caractères)
603
+ MAX_LEN = 300
604
+ # Zone de saisie
605
+ custom_instructions = st.text_area(
606
+ "Instructions (succinctes) supplémentaires pour la réponse",
607
+ placeholder="Ex: Insister sur l'aspect budgétaire, mentionner le projet de loi X, conclure en rappelant la mobilisation du Gouvernement contre toutes les violences exercées à l’égard des femmes...",
608
+ height=100,
609
+ key="custom_instructions"
610
+ )
611
+ # Affichage du compteur
612
+ if custom_instructions:
613
+ remaining = MAX_LEN - len(custom_instructions)
614
+ if remaining >= 0:
615
+ st.caption(f"✍️ {len(custom_instructions)}/{MAX_LEN} caractères utilisés")
616
+ else:
617
+ st.warning(f"⚠️ Vous avez dépassé la limite de {MAX_LEN} caractères ({len(custom_instructions)} actuellement).")
618
+ # Optionnel : tronquer automatiquement
619
+ custom_instructions = custom_instructions[:MAX_LEN]
620
+ st.caption(f"✍️ {MAX_LEN}/{MAX_LEN} caractères utilisés (texte tronqué)")
621
+ else:
622
+ st.caption(f"✍️ 0/{MAX_LEN} caractères utilisés")
623
+
624
+ # --- 9. Génération de la réponse et historique ---
625
+ if "full_historique" not in st.session_state:
626
+ st.session_state.full_historique = {}
627
+
628
+ if st.button("Générer la réponse", type="primary", key="generate_button"):
629
+ if not question.strip():
630
+ st.warning("Veuillez entrer une question.")
631
+ else:
632
+ with st.spinner("Recherche de contextes pertinents et génération de la réponse..."):
633
+ try:
634
+ # Traduire le choix du radio en valeur pour le backend
635
+ mode_value = "analyse" if mode == "Analyse synthétique" else "parlementaire"
636
+
637
+ response = requests.post(
638
+ "http://127.0.0.1:8000/ask",
639
+ json={
640
+ "question": question,
641
+ "detail_juridique": detail_juridique,
642
+ "longueur": longueur,
643
+ "mode": mode_value,
644
+ "response_orientation": selected_orientation, # ← AJOUTEZ CECI
645
+ "custom_instructions": custom_instructions # ← ET CECI
646
+ }
647
+ )
648
+
649
+ response.raise_for_status()
650
+ response_data = response.json()
651
+
652
+ # Stocker la réponse dans l'historique complet
653
+ st.session_state.full_historique[question] = {
654
+ "response": response_data["response"],
655
+ "context": response_data["context"],
656
+ "sources": response_data["sources"],
657
+ "mode": mode_value
658
+ }
659
+
660
+ # Afficher les onglets de réponse et sources
661
+ tab1, tab2 = st.tabs(["📜 Réponse", "🔍 Sources"])
662
+ with tab1:
663
+ st.markdown("### Réponse générée")
664
+ # Affichage unique de la réponse avec un id
665
+ st.markdown(
666
+ f'<div id="response-text" class="response-text">{response_data["response"]}</div>',
667
+ unsafe_allow_html=True
668
+ )
669
+
670
+ # --- Bouton Export TXT ---
671
+ st.download_button(
672
+ label="⬇️ Exporter en .txt",
673
+ data=response_data["response"],
674
+ file_name="reponse.txt",
675
+ mime="text/plain"
676
+ )
677
+
678
+ # Afficher les questions similaires dans l'onglet Sources
679
+ with tab2:
680
+ st.markdown("## 📚 Anciennes QE similaires")
681
+ if "similar_questions" in response_data and response_data["similar_questions"]:
682
+ for idx, similar_item in enumerate(response_data["similar_questions"]):
683
+ with st.expander(f"{idx + 1}. {similar_item.get('question', '')[:100]}..."):
684
+ st.markdown(f"**Date de la réponse :** {similar_item.get('date', 'Inconnue')}")
685
+ st.markdown(f"**Chambre :** {similar_item.get('chambre', 'Inconnue')}")
686
+ st.markdown(f"**Question :** {similar_item.get('question', '')}")
687
+ st.markdown(f"**Réponse :** {similar_item.get('reponse', '')}")
688
+
689
+ st.markdown("## 📜 Textes juridiques")
690
+ if "sources" in response_data and response_data["sources"]:
691
+ for source in response_data["sources"]:
692
+ st.markdown(f"- {source}")
693
+
694
+ st.markdown("## 📄 Documents du RAG")
695
+ documents = ["Document 1", "Document 2", "Document 3"] # Exemple
696
+ for doc in documents:
697
+ st.markdown(f"- {doc}")
698
+
699
+ except requests.exceptions.RequestException as e:
700
+ if "Connection refused" in str(e):
701
+ st.error("⚠️ Le serveur backend n'est pas démarré. Vérifiez que `backend/app.py` est lancé.")
702
+ elif hasattr(e, 'response') and e.response.status_code == 500:
703
+ st.error(f"⚠️ Erreur serveur: {e.response.json().get('detail', str(e))}")
704
+ else:
705
+ st.error(f"⚠️ Erreur inattendue: {e}")
706
+
707
+ # --- 10. Affichage de l'historique complet ---
708
+ if st.session_state.full_historique:
709
+ st.markdown("## 📚 Historique des questions")
710
+
711
+ for idx, (question, data) in enumerate(st.session_state.full_historique.items()):
712
+ with st.expander(f"{idx+1}. {question[:80]}..."):
713
+ # Afficher la question complète
714
+ st.markdown("**Question complète :**")
715
+ st.write(question)
716
+
717
+ # Afficher la réponse complète
718
+ st.markdown("**Réponse générée :**")
719
+ st.markdown(
720
+ f'<div class="response-text">{data["response"]}</div>',
721
+ unsafe_allow_html=True
722
+ )
723
+
724
+ # --- Bouton Export (question + réponse) ---
725
+ from docx import Document
726
+ import io
727
+
728
+ doc = Document()
729
+ doc.add_heading("Question", level=1)
730
+ doc.add_paragraph(question)
731
+ doc.add_heading("Réponse", level=1)
732
+ doc.add_paragraph(data["response"])
733
+
734
+ buffer_word = io.BytesIO()
735
+ doc.save(buffer_word)
736
+ buffer_word.seek(0)
737
+
738
+ st.download_button(
739
+ label="⬇️ Exporter Question + Réponse (Word)",
740
+ data=buffer_word,
741
+ file_name=f"historique_{idx+1}.docx",
742
+ mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document"
743
+ )
744
+
745
+ # --- Sources associées ---
746
+ if "sources" in data and data["sources"]:
747
+ with st.expander("🔍 Sources utilisées"):
748
+ for i, (context, source) in enumerate(zip(data["context"], data["sources"])):
749
+ with st.expander(f"Source {i+1}: {source}"):
750
+ st.markdown(
751
+ f'<div class="source-text">{context}</div>',
752
+ unsafe_allow_html=True
753
+ )
754
+
755
+
756
+ st.title("📑 Générateur de réponses parlementaires")
757
+
758
+ question = st.text_input("Posez votre question :")
759
+
760
+ if question:
761
+ request = {"question": question}
762
+ response = generate_response(request)
763
+ st.write("### Réponse générée :")
764
+ st.write(response)
requirements.txt CHANGED
@@ -1,3 +1,6 @@
1
  altair
2
  pandas
3
- streamlit
 
 
 
 
1
  altair
2
  pandas
3
+ streamlit
4
+ sentence-transformers
5
+ transformers
6
+ torch