django-lucy-assist 1.1.1__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,491 @@
1
+ """
2
+ Service d'integration avec Mistral AI API.
3
+
4
+ Optimisations tokens:
5
+ - Cache du contexte projet via ProjectContextService
6
+ - Resume des conversations longues
7
+ - Compression intelligente du contexte
8
+
9
+ Tools CRUD:
10
+ - Mistral peut executer des actions CRUD via les tools
11
+ - Les tools sont executes cote serveur avec les permissions de l'utilisateur
12
+ """
13
+ import json
14
+ from typing import Generator, List, Dict, Any
15
+
16
+ from mistralai import Mistral
17
+
18
+ from lucy_assist.utils.log_utils import LogUtils
19
+ from lucy_assist.constantes import LucyAssistConstantes
20
+ from lucy_assist.services.tools_definition import LUCY_ASSIST_TOOLS
21
+ from lucy_assist.conf import lucy_assist_settings
22
+
23
+
24
+ class MistralService:
25
+ """Service pour interagir avec l'API Mistral AI."""
26
+
27
+ MAX_TOKENS = 4096
28
+
29
+ # Seuils pour l'optimisation
30
+ MAX_MESSAGES_BEFORE_SUMMARY = 10 # Resumer apres 10 messages
31
+ MAX_CONTEXT_TOKENS = 2000 # Limiter le contexte a 2000 tokens estimes
32
+
33
+ def __init__(self):
34
+ self.api_key = lucy_assist_settings.MISTRAL_LUCY_API_KEY
35
+ if not self.api_key:
36
+ raise ValueError("MISTRAL_LUCY_API_KEY non configuree dans les settings")
37
+
38
+ self.client = Mistral(api_key=self.api_key)
39
+ self._project_context_service = None
40
+ self._tools = LUCY_ASSIST_TOOLS
41
+ self._model = lucy_assist_settings.MISTRAL_MODEL
42
+
43
+ @property
44
+ def project_context_service(self):
45
+ """Lazy loading du service de contexte projet."""
46
+ if self._project_context_service is None:
47
+ from lucy_assist.services.project_context_service import ProjectContextService
48
+ self._project_context_service = ProjectContextService()
49
+ return self._project_context_service
50
+
51
+ def _build_system_prompt(
52
+ self,
53
+ page_context: Dict,
54
+ user,
55
+ user_question: str = ""
56
+ ) -> str:
57
+ """
58
+ Construit le prompt systeme avec le contexte optimise.
59
+
60
+ Utilise le prompt stocke en base de donnees et le cache
61
+ pour reduire la redondance des informations sur le projet.
62
+ """
63
+ # Recuperer les permissions utilisateur (compressees)
64
+ user_permissions = []
65
+ if hasattr(user, 'get_all_permissions'):
66
+ # Ne garder que les permissions pertinentes (sans prefixe d'app commun)
67
+ all_perms = list(user.get_all_permissions())
68
+ user_permissions = [p.split('.')[-1] for p in all_perms[:15]]
69
+
70
+ # Recuperer le contexte projet optimise depuis le cache
71
+ page_url = page_context.get('page_url', page_context.get('url', ''))
72
+ optimized_context = self.project_context_service.get_optimized_context(
73
+ page_url=page_url,
74
+ user_question=user_question
75
+ )
76
+
77
+ # Fusionner le contexte de page avec le contexte projet cache
78
+ enriched_context = {
79
+ 'page': page_context,
80
+ 'projet': optimized_context.get('relevant_info', {}),
81
+ 'cache_stats': optimized_context.get('stats', {})
82
+ }
83
+
84
+ # Recuperer la configuration avec le prompt systeme
85
+ from lucy_assist.models import ConfigurationLucyAssist
86
+ config = ConfigurationLucyAssist.get_config()
87
+
88
+ # Generer la description des modeles disponibles
89
+ available_models = config.get_available_models_description()
90
+
91
+ # Construire le prompt depuis la configuration
92
+ prompt = config.get_system_prompt(
93
+ page_context=json.dumps(enriched_context, ensure_ascii=False, indent=2),
94
+ user_permissions=', '.join(user_permissions),
95
+ available_models=available_models
96
+ )
97
+
98
+ return prompt
99
+
100
+ def _optimize_messages(self, messages: List) -> List[Dict]:
101
+ """
102
+ Optimise l'historique des messages pour reduire les tokens.
103
+
104
+ Pour les conversations longues, resume les anciens messages
105
+ au lieu de les envoyer en entier.
106
+ """
107
+ formatted = self._format_messages(messages)
108
+
109
+ if len(formatted) <= self.MAX_MESSAGES_BEFORE_SUMMARY:
110
+ return formatted
111
+
112
+ # Resumer la conversation
113
+ summary_data = self.project_context_service.summarize_conversation(
114
+ formatted,
115
+ max_tokens=500
116
+ )
117
+
118
+ if not summary_data:
119
+ return formatted
120
+
121
+ # Reconstruire les messages avec le resume
122
+ optimized = []
123
+
124
+ # Ajouter les premiers messages
125
+ optimized.extend(summary_data['first_messages'])
126
+
127
+ # Ajouter le resume comme message systeme
128
+ optimized.append({
129
+ 'role': 'user',
130
+ 'content': f"[Note: {summary_data['original_count'] - 4} messages resumes]\n{summary_data['summary']}"
131
+ })
132
+
133
+ # Ajouter les derniers messages
134
+ optimized.extend(summary_data['last_messages'])
135
+
136
+ LogUtils.info(
137
+ f"Conversation optimisee: {len(formatted)} -> {len(optimized)} messages, "
138
+ f"~{summary_data.get('tokens_saved_estimate', 0)} tokens economises"
139
+ )
140
+
141
+ return optimized
142
+
143
+ def _format_messages(self, messages: List) -> List[Dict]:
144
+ """Formate les messages pour l'API Mistral."""
145
+ formatted = []
146
+
147
+ for msg in messages:
148
+ role = "user" if msg.repondant == LucyAssistConstantes.Repondant.UTILISATEUR else "assistant"
149
+ formatted.append({
150
+ "role": role,
151
+ "content": msg.contenu
152
+ })
153
+
154
+ return formatted
155
+
156
+ def _convert_tool_results_for_mistral(self, tool_results: List[Dict]) -> List[Dict]:
157
+ """Convertit les resultats de tools au format Mistral."""
158
+ mistral_results = []
159
+ for result in tool_results:
160
+ mistral_results.append({
161
+ "role": "tool",
162
+ "tool_call_id": result.get('tool_use_id', result.get('tool_call_id')),
163
+ "content": result.get('content', '{}')
164
+ })
165
+ return mistral_results
166
+
167
+ def chat_completion_stream(
168
+ self,
169
+ messages: List,
170
+ page_context: Dict,
171
+ user,
172
+ tool_executor=None
173
+ ) -> Generator[Dict[str, Any], None, None]:
174
+ """
175
+ Genere une reponse en streaming avec support des tools.
176
+
177
+ Args:
178
+ messages: Liste des messages de la conversation
179
+ page_context: Contexte de la page courante
180
+ user: Utilisateur Django
181
+ tool_executor: Callable pour executer les tools (optionnel)
182
+
183
+ Yields:
184
+ Dict avec 'type' (content/tool_use/tool_result/usage/error) et les donnees associees
185
+ """
186
+ try:
187
+ # Extraire la question utilisateur du dernier message
188
+ user_question = ""
189
+ if messages:
190
+ last_msg = messages[-1] if hasattr(messages[-1], 'contenu') else messages[-1]
191
+ user_question = getattr(last_msg, 'contenu', '') if hasattr(last_msg, 'contenu') else str(last_msg)
192
+
193
+ system_prompt = self._build_system_prompt(page_context, user, user_question)
194
+
195
+ # Utiliser l'optimisation des messages pour les longues conversations
196
+ formatted_messages = self._optimize_messages(messages)
197
+
198
+ if not formatted_messages:
199
+ yield {'type': 'error', 'error': 'Aucun message a traiter'}
200
+ return
201
+
202
+ # Ajouter le system prompt comme premier message
203
+ messages_with_system = [
204
+ {"role": "system", "content": system_prompt}
205
+ ] + formatted_messages
206
+
207
+ # Boucle pour gerer les appels de tools
208
+ current_messages = messages_with_system.copy()
209
+ max_tool_iterations = 5 # Limite de securite
210
+
211
+ for iteration in range(max_tool_iterations):
212
+ response_text = ""
213
+ tool_calls = []
214
+ total_input_tokens = 0
215
+ total_output_tokens = 0
216
+
217
+ # Streaming avec Mistral
218
+ stream_response = self.client.chat.stream(
219
+ model=self._model,
220
+ max_tokens=self.MAX_TOKENS,
221
+ messages=current_messages,
222
+ tools=self._tools,
223
+ tool_choice="auto"
224
+ )
225
+
226
+ # Collecter le contenu et les tool calls
227
+ for chunk in stream_response:
228
+ if chunk.data.choices:
229
+ choice = chunk.data.choices[0]
230
+ delta = choice.delta
231
+
232
+ # Contenu textuel
233
+ if delta.content:
234
+ response_text += delta.content
235
+ yield {'type': 'content', 'content': delta.content}
236
+
237
+ # Tool calls
238
+ if delta.tool_calls:
239
+ for tc in delta.tool_calls:
240
+ # Accumuler les tool calls
241
+ if tc.id:
242
+ tool_calls.append({
243
+ 'id': tc.id,
244
+ 'name': tc.function.name if tc.function else '',
245
+ 'arguments': tc.function.arguments if tc.function else ''
246
+ })
247
+ elif tool_calls and tc.function:
248
+ # Continuation du dernier tool call
249
+ tool_calls[-1]['arguments'] += tc.function.arguments or ''
250
+
251
+ # Usage tokens
252
+ if chunk.data.usage:
253
+ total_input_tokens = chunk.data.usage.prompt_tokens
254
+ total_output_tokens = chunk.data.usage.completion_tokens
255
+
256
+ # Verifier le stop reason
257
+ finish_reason = None
258
+ if stream_response:
259
+ # Le dernier chunk contient le finish_reason
260
+ pass # finish_reason est dans le dernier choice
261
+
262
+ # Si pas de tool calls, on a fini
263
+ if not tool_calls:
264
+ if total_input_tokens or total_output_tokens:
265
+ yield {
266
+ 'type': 'usage',
267
+ 'input_tokens': total_input_tokens,
268
+ 'output_tokens': total_output_tokens,
269
+ 'total_tokens': total_input_tokens + total_output_tokens
270
+ }
271
+ return
272
+
273
+ # Executer les tools
274
+ tool_results = []
275
+ assistant_tool_calls = []
276
+
277
+ for tool_call in tool_calls:
278
+ tool_name = tool_call['name']
279
+ try:
280
+ tool_input = json.loads(tool_call['arguments'])
281
+ except json.JSONDecodeError:
282
+ tool_input = {}
283
+
284
+ yield {
285
+ 'type': 'tool_use',
286
+ 'tool_name': tool_name,
287
+ 'tool_input': tool_input
288
+ }
289
+
290
+ # Preparer le format pour l'assistant message
291
+ assistant_tool_calls.append({
292
+ "id": tool_call['id'],
293
+ "type": "function",
294
+ "function": {
295
+ "name": tool_name,
296
+ "arguments": tool_call['arguments']
297
+ }
298
+ })
299
+
300
+ # Executer le tool si un executor est fourni
301
+ if tool_executor:
302
+ try:
303
+ result = tool_executor(
304
+ tool_name,
305
+ tool_input,
306
+ user
307
+ )
308
+ tool_results.append({
309
+ 'role': 'tool',
310
+ 'tool_call_id': tool_call['id'],
311
+ 'content': json.dumps(result, ensure_ascii=False)
312
+ })
313
+ yield {
314
+ 'type': 'tool_result',
315
+ 'tool_name': tool_name,
316
+ 'result': result
317
+ }
318
+ except Exception as e:
319
+ error_result = {'error': str(e)}
320
+ tool_results.append({
321
+ 'role': 'tool',
322
+ 'tool_call_id': tool_call['id'],
323
+ 'content': json.dumps(error_result)
324
+ })
325
+ yield {
326
+ 'type': 'tool_error',
327
+ 'tool_name': tool_name,
328
+ 'error': str(e)
329
+ }
330
+ else:
331
+ # Pas d'executor, on ne peut pas executer le tool
332
+ tool_results.append({
333
+ 'role': 'tool',
334
+ 'tool_call_id': tool_call['id'],
335
+ 'content': json.dumps({'error': 'Tool executor not available'})
336
+ })
337
+
338
+ # Ajouter les messages pour continuer la conversation
339
+ # Message assistant avec tool_calls
340
+ current_messages.append({
341
+ 'role': 'assistant',
342
+ 'content': response_text or None,
343
+ 'tool_calls': assistant_tool_calls
344
+ })
345
+
346
+ # Messages tool results
347
+ current_messages.extend(tool_results)
348
+
349
+ except Exception as e:
350
+ LogUtils.error(f"Erreur lors de l'appel Mistral: {e}")
351
+ error_message = str(e)
352
+ if "rate" in error_message.lower() or "limit" in error_message.lower():
353
+ yield {'type': 'error', 'error': 'Service temporairement surcharge, veuillez reessayer'}
354
+ elif "connection" in error_message.lower() or "connect" in error_message.lower():
355
+ yield {'type': 'error', 'error': 'Impossible de se connecter au service IA'}
356
+ else:
357
+ yield {'type': 'error', 'error': error_message}
358
+
359
+ def chat_completion(
360
+ self,
361
+ messages: List,
362
+ page_context: Dict,
363
+ user
364
+ ) -> Dict[str, Any]:
365
+ """
366
+ Genere une reponse complete (non-streaming).
367
+
368
+ Returns:
369
+ Dict avec 'content', 'tokens_utilises', ou 'error'
370
+ """
371
+ try:
372
+ # Extraire la question utilisateur
373
+ user_question = ""
374
+ if messages:
375
+ last_msg = messages[-1] if hasattr(messages[-1], 'contenu') else messages[-1]
376
+ user_question = getattr(last_msg, 'contenu', '') if hasattr(last_msg, 'contenu') else str(last_msg)
377
+
378
+ system_prompt = self._build_system_prompt(page_context, user, user_question)
379
+
380
+ # Utiliser l'optimisation des messages
381
+ formatted_messages = self._optimize_messages(messages)
382
+
383
+ if not formatted_messages:
384
+ return {'error': 'Aucun message a traiter'}
385
+
386
+ # Ajouter le system prompt comme premier message
387
+ messages_with_system = [
388
+ {"role": "system", "content": system_prompt}
389
+ ] + formatted_messages
390
+
391
+ response = self.client.chat.complete(
392
+ model=self._model,
393
+ max_tokens=self.MAX_TOKENS,
394
+ messages=messages_with_system
395
+ )
396
+
397
+ content = ""
398
+ if response.choices:
399
+ content = response.choices[0].message.content or ""
400
+
401
+ total_tokens = 0
402
+ input_tokens = 0
403
+ output_tokens = 0
404
+ if response.usage:
405
+ input_tokens = response.usage.prompt_tokens
406
+ output_tokens = response.usage.completion_tokens
407
+ total_tokens = input_tokens + output_tokens
408
+
409
+ return {
410
+ 'content': content,
411
+ 'tokens_utilises': total_tokens,
412
+ 'input_tokens': input_tokens,
413
+ 'output_tokens': output_tokens
414
+ }
415
+
416
+ except Exception as e:
417
+ LogUtils.error(f"Erreur lors de l'appel Mistral: {e}")
418
+ return {'error': str(e)}
419
+
420
+ def analyze_code_for_bug(
421
+ self,
422
+ error_message: str,
423
+ code_context: str,
424
+ user_description: str
425
+ ) -> Dict[str, Any]:
426
+ """
427
+ Analyse du code pour detecter un bug potentiel.
428
+
429
+ Returns:
430
+ Dict avec 'is_bug', 'analysis', 'recommendation'
431
+ """
432
+ prompt = f"""Analyse le probleme suivant signale par un utilisateur:
433
+
434
+ Description de l'utilisateur: {user_description}
435
+
436
+ Message d'erreur (si disponible): {error_message}
437
+
438
+ Code source pertinent:
439
+ ```
440
+ {code_context}
441
+ ```
442
+
443
+ Reponds au format JSON avec les cles suivantes:
444
+ - is_bug: boolean (true si c'est un bug dans le code, false si c'est une erreur utilisateur)
445
+ - analysis: string (explication du probleme)
446
+ - recommendation: string (recommandation pour resoudre le probleme)
447
+ - severity: string (low/medium/high si c'est un bug)
448
+ """
449
+
450
+ try:
451
+ response = self.client.chat.complete(
452
+ model=self._model,
453
+ max_tokens=1024,
454
+ messages=[
455
+ {"role": "system", "content": "Tu es un expert en analyse de bugs. Reponds uniquement en JSON valide."},
456
+ {"role": "user", "content": prompt}
457
+ ]
458
+ )
459
+
460
+ content = ""
461
+ if response.choices:
462
+ content = response.choices[0].message.content or "{}"
463
+
464
+ # Essayer de parser le JSON
465
+ try:
466
+ # Extraire le JSON de la reponse
467
+ import re
468
+ json_match = re.search(r'\{[^{}]*\}', content, re.DOTALL)
469
+ if json_match:
470
+ return json.loads(json_match.group())
471
+ except json.JSONDecodeError:
472
+ pass
473
+
474
+ return {
475
+ 'is_bug': False,
476
+ 'analysis': content,
477
+ 'recommendation': 'Contactez le support si le probleme persiste.'
478
+ }
479
+
480
+ except Exception as e:
481
+ LogUtils.error(f"Erreur lors de l'analyse de bug: {e}")
482
+ return {
483
+ 'error': str(e),
484
+ 'is_bug': False,
485
+ 'analysis': 'Impossible d\'analyser le probleme',
486
+ 'recommendation': 'Veuillez contacter le support technique.'
487
+ }
488
+
489
+
490
+ # Alias pour compatibilite avec l'ancien code
491
+ ClaudeService = MistralService
@@ -332,23 +332,40 @@ class ProjectContextService:
332
332
  return None
333
333
 
334
334
  def _detect_model_from_question(self, question: str) -> Optional[str]:
335
- """Détecte si la question mentionne un modèle."""
336
- # Mots-clés courants et leurs modèles associés
337
- model_keywords = {
338
- 'membre': 'Membre',
339
- 'adhésion': 'Adhesion',
340
- 'cotisation': 'Cotisation',
341
- 'utilisateur': 'Utilisateur',
342
- 'user': 'Utilisateur',
343
- 'paiement': 'Paiement',
344
- 'facture': 'Facture',
345
- 'structure': 'Structure',
346
- }
335
+ """
336
+ Détecte si la question mentionne un modèle.
337
+
338
+ Utilise la liste dynamique des modèles Django enregistrés
339
+ au lieu d'une liste hardcodée.
340
+ """
341
+ from django.apps import apps
342
+ from lucy_assist.conf import lucy_assist_settings
347
343
 
348
344
  question_lower = question.lower()
349
- for keyword, model in model_keywords.items():
350
- if keyword in question_lower:
351
- return model
345
+ apps_prefix = lucy_assist_settings.PROJECT_APPS_PREFIX
346
+
347
+ for app_config in apps.get_app_configs():
348
+ # Filtrer les apps si un préfixe est configuré
349
+ if apps_prefix and not app_config.name.startswith(apps_prefix):
350
+ continue
351
+
352
+ # Ignorer les apps Django internes et lucy_assist
353
+ if app_config.name.startswith('django.') or app_config.name == 'lucy_assist':
354
+ continue
355
+
356
+ for model in app_config.get_models():
357
+ model_name = model.__name__
358
+ model_name_lower = model_name.lower()
359
+
360
+ # Vérifier si le nom du modèle apparaît dans la question
361
+ if model_name_lower in question_lower:
362
+ return model_name
363
+
364
+ # Vérifier aussi le verbose_name si disponible
365
+ if hasattr(model._meta, 'verbose_name'):
366
+ verbose_name = str(model._meta.verbose_name).lower()
367
+ if verbose_name in question_lower:
368
+ return model_name
352
369
 
353
370
  return None
354
371
 
@@ -1,7 +1,7 @@
1
1
  """
2
- Service d'exécution des tools pour Lucy Assist.
2
+ Service d'execution des tools pour Lucy Assist.
3
3
 
4
- Ce service fait le lien entre les appels de tools de Claude
4
+ Ce service fait le lien entre les appels de tools de Mistral
5
5
  et les services CRUD/Context de l'application.
6
6
  """
7
7
  from typing import Dict, Any
@@ -17,7 +17,7 @@ from lucy_assist.services.bug_notification_service import BugNotificationService
17
17
 
18
18
 
19
19
  class ToolExecutorService:
20
- """Service pour exécuter les tools appelés par Claude."""
20
+ """Service pour executer les tools appeles par Mistral."""
21
21
 
22
22
  def __init__(self, user):
23
23
  self.user = user
@@ -270,10 +270,10 @@ class ToolExecutorService:
270
270
 
271
271
  def _handle_analyze_bug(self, params: Dict) -> Dict:
272
272
  """
273
- Analyse un bug potentiel en utilisant GitLab et Claude.
274
- Si un bug est détecté, envoie automatiquement une notification à Revolucy.
273
+ Analyse un bug potentiel en utilisant GitLab et Mistral.
274
+ Si un bug est detecte, envoie automatiquement une notification a Revolucy.
275
275
  """
276
- from lucy_assist.services.claude_service import ClaudeService
276
+ from lucy_assist.services.mistral_service import MistralService
277
277
 
278
278
  user_description = params.get('user_description', '')
279
279
  error_message = params.get('error_message', '')
@@ -331,16 +331,16 @@ class ToolExecutorService:
331
331
  LogUtils.error(f"Erreur lors de la récupération du code GitLab: {e}")
332
332
  code_context = f"Impossible de récupérer le code source: {str(e)}"
333
333
 
334
- # Analyser le bug avec Claude
334
+ # Analyser le bug avec Mistral
335
335
  try:
336
- claude_service = ClaudeService()
337
- bug_analysis = claude_service.analyze_code_for_bug(
336
+ mistral_service = MistralService()
337
+ bug_analysis = mistral_service.analyze_code_for_bug(
338
338
  error_message=error_message,
339
339
  code_context=code_context,
340
340
  user_description=user_description
341
341
  )
342
342
  except Exception as e:
343
- LogUtils.error(f"Erreur lors de l'analyse Claude: {e}")
343
+ LogUtils.error(f"Erreur lors de l'analyse Mistral: {e}")
344
344
  bug_analysis = {
345
345
  'is_bug': False,
346
346
  'analysis': f'Impossible d\'analyser: {str(e)}',