django-lucy-assist 0.1.0__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.
Files changed (44) hide show
  1. django_lucy_assist-0.1.0.dist-info/METADATA +206 -0
  2. django_lucy_assist-0.1.0.dist-info/RECORD +44 -0
  3. django_lucy_assist-0.1.0.dist-info/WHEEL +5 -0
  4. django_lucy_assist-0.1.0.dist-info/top_level.txt +1 -0
  5. lucy_assist/__init__.py +11 -0
  6. lucy_assist/admin.py +22 -0
  7. lucy_assist/apps.py +10 -0
  8. lucy_assist/conf.py +103 -0
  9. lucy_assist/constantes.py +120 -0
  10. lucy_assist/context_processors.py +65 -0
  11. lucy_assist/migrations/0001_initial.py +92 -0
  12. lucy_assist/migrations/__init__.py +0 -0
  13. lucy_assist/models/__init__.py +14 -0
  14. lucy_assist/models/base.py +54 -0
  15. lucy_assist/models/configuration.py +175 -0
  16. lucy_assist/models/conversation.py +54 -0
  17. lucy_assist/models/message.py +45 -0
  18. lucy_assist/models/project_context_cache.py +213 -0
  19. lucy_assist/services/__init__.py +21 -0
  20. lucy_assist/services/bug_notification_service.py +183 -0
  21. lucy_assist/services/claude_service.py +417 -0
  22. lucy_assist/services/context_service.py +350 -0
  23. lucy_assist/services/crud_service.py +364 -0
  24. lucy_assist/services/gitlab_service.py +248 -0
  25. lucy_assist/services/project_context_service.py +412 -0
  26. lucy_assist/services/tool_executor_service.py +343 -0
  27. lucy_assist/services/tools_definition.py +229 -0
  28. lucy_assist/signals.py +25 -0
  29. lucy_assist/static/lucy_assist/css/lucy-assist.css +160 -0
  30. lucy_assist/static/lucy_assist/image/icon-lucy.png +0 -0
  31. lucy_assist/static/lucy_assist/js/lucy-assist.js +824 -0
  32. lucy_assist/templates/lucy_assist/chatbot_sidebar.html +419 -0
  33. lucy_assist/templates/lucy_assist/partials/documentation_content.html +107 -0
  34. lucy_assist/tests/__init__.py +0 -0
  35. lucy_assist/tests/factories/__init__.py +15 -0
  36. lucy_assist/tests/factories/lucy_assist_factories.py +109 -0
  37. lucy_assist/tests/test_lucy_assist.py +186 -0
  38. lucy_assist/urls.py +36 -0
  39. lucy_assist/utils/__init__.py +7 -0
  40. lucy_assist/utils/log_utils.py +59 -0
  41. lucy_assist/utils/message_utils.py +130 -0
  42. lucy_assist/utils/token_utils.py +87 -0
  43. lucy_assist/views/__init__.py +13 -0
  44. lucy_assist/views/api_views.py +595 -0
@@ -0,0 +1,92 @@
1
+ # Generated migration for lucy_assist
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='ConfigurationLucyAssist',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('created_date', models.DateTimeField(auto_now_add=True)),
22
+ ('updated_date', models.DateTimeField(auto_now=True, null=True, blank=True)),
23
+ ('is_active', models.BooleanField(default=True)),
24
+ ('tokens_disponibles', models.BigIntegerField(default=0)),
25
+ ('prix_par_million_tokens', models.DecimalField(decimal_places=2, default=10.0, max_digits=10)),
26
+ ('questions_frequentes', models.JSONField(blank=True, default=list)),
27
+ ('actif', models.BooleanField(default=True)),
28
+ ('avatar', models.ImageField(blank=True, null=True, upload_to='')),
29
+ ('model_app_mapping', models.JSONField(blank=True, default=dict)),
30
+ ],
31
+ options={
32
+ 'verbose_name': 'Configuration Lucy Assist',
33
+ 'verbose_name_plural': 'Configuration Lucy Assist',
34
+ },
35
+ ),
36
+ migrations.CreateModel(
37
+ name='Conversation',
38
+ fields=[
39
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
40
+ ('created_date', models.DateTimeField(auto_now_add=True)),
41
+ ('updated_date', models.DateTimeField(auto_now=True, null=True, blank=True)),
42
+ ('is_active', models.BooleanField(default=True)),
43
+ ('titre', models.CharField(blank=True, max_length=255, null=True)),
44
+ ('page_contexte', models.CharField(blank=True, max_length=500, null=True)),
45
+ ('utilisateur', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lucy_assist_conversations', to=settings.AUTH_USER_MODEL)),
46
+ ],
47
+ options={
48
+ 'verbose_name': 'Conversation Lucy Assist',
49
+ 'verbose_name_plural': 'Conversations Lucy Assist',
50
+ 'ordering': ['-created_date'],
51
+ },
52
+ ),
53
+ migrations.CreateModel(
54
+ name='Message',
55
+ fields=[
56
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
57
+ ('created_date', models.DateTimeField(auto_now_add=True)),
58
+ ('updated_date', models.DateTimeField(auto_now=True, null=True, blank=True)),
59
+ ('is_active', models.BooleanField(default=True)),
60
+ ('repondant', models.CharField(choices=[('UTILISATEUR', 'Utilisateur'), ('CHATBOT', 'Lucy Assist')], max_length=20)),
61
+ ('contenu', models.TextField()),
62
+ ('tokens_utilises', models.IntegerField(default=0)),
63
+ ('type_action', models.CharField(blank=True, choices=[('AIDE_NAVIGATION', 'Aide à la navigation'), ('RECHERCHE', "Recherche d'objets"), ('CRUD', "Création/Modification/Suppresion d'objets"), ('ANALYSE_BUG', 'Analyse de bug'), ('EXPLICATION', 'Explication de fonctionnalité')], max_length=50, null=True)),
64
+ ('metadata', models.JSONField(blank=True, default=dict)),
65
+ ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='lucy_assist.conversation')),
66
+ ],
67
+ options={
68
+ 'verbose_name': 'Message Lucy Assist',
69
+ 'verbose_name_plural': 'Messages Lucy Assist',
70
+ 'ordering': ['created_date'],
71
+ },
72
+ ),
73
+ migrations.CreateModel(
74
+ name='ProjectContextCache',
75
+ fields=[
76
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
77
+ ('created_date', models.DateTimeField(auto_now_add=True)),
78
+ ('updated_date', models.DateTimeField(auto_now=True, null=True, blank=True)),
79
+ ('is_active', models.BooleanField(default=True)),
80
+ ('cache_key', models.CharField(db_index=True, max_length=255, unique=True)),
81
+ ('contenu', models.JSONField(default=dict)),
82
+ ('content_hash', models.CharField(blank=True, max_length=64)),
83
+ ('expire_at', models.DateTimeField()),
84
+ ('tokens_economises', models.BigIntegerField(default=0)),
85
+ ('hit_count', models.IntegerField(default=0)),
86
+ ],
87
+ options={
88
+ 'verbose_name': 'Cache contexte projet',
89
+ 'verbose_name_plural': 'Caches contexte projet',
90
+ },
91
+ ),
92
+ ]
File without changes
@@ -0,0 +1,14 @@
1
+ from .base import LucyAssistBaseModel
2
+ from .conversation import Conversation
3
+ from .message import Message
4
+ from .configuration import ConfigurationLucyAssist, get_default_model_app_mapping
5
+ from .project_context_cache import ProjectContextCache
6
+
7
+ __all__ = [
8
+ 'LucyAssistBaseModel',
9
+ 'Conversation',
10
+ 'Message',
11
+ 'ConfigurationLucyAssist',
12
+ 'get_default_model_app_mapping',
13
+ 'ProjectContextCache',
14
+ ]
@@ -0,0 +1,54 @@
1
+ """
2
+ Modèle de base pour Lucy Assist.
3
+ Utilise le modèle de base configuré dans les settings ou un modèle par défaut.
4
+ """
5
+ from django.db import models
6
+
7
+ from lucy_assist.conf import lucy_assist_settings
8
+
9
+
10
+ def get_base_model():
11
+ """
12
+ Retourne le modèle de base à utiliser pour les modèles Lucy Assist.
13
+ Permet de personnaliser le modèle de base via les settings Django.
14
+ """
15
+ base_model = lucy_assist_settings.BASE_MODEL
16
+
17
+ if base_model is None:
18
+ # Utiliser un modèle par défaut simple
19
+ return LucyAssistBaseModel
20
+
21
+ if isinstance(base_model, str):
22
+ # Importer le modèle depuis le chemin string
23
+ from django.utils.module_loading import import_string
24
+ return import_string(base_model)
25
+
26
+ return base_model
27
+
28
+
29
+ class LucyAssistBaseModel(models.Model):
30
+ """
31
+ Modèle de base par défaut pour Lucy Assist.
32
+ Fournit les champs minimaux nécessaires pour le tracking.
33
+ """
34
+ created_date = models.DateTimeField(auto_now_add=True)
35
+ updated_date = models.DateTimeField(auto_now=True, null=True, blank=True)
36
+ is_active = models.BooleanField(default=True)
37
+
38
+ class Meta:
39
+ abstract = True
40
+ ordering = ["-created_date"]
41
+
42
+
43
+ # Cache pour le modèle de base résolu
44
+ _resolved_base_model = None
45
+
46
+
47
+ def get_lucy_assist_model_base():
48
+ """
49
+ Retourne le modèle de base résolu (avec cache).
50
+ """
51
+ global _resolved_base_model
52
+ if _resolved_base_model is None:
53
+ _resolved_base_model = get_base_model()
54
+ return _resolved_base_model
@@ -0,0 +1,175 @@
1
+ from django.db import models
2
+ from django.core.cache import cache
3
+
4
+ from lucy_assist.models.base import LucyAssistBaseModel
5
+ from lucy_assist.conf import lucy_assist_settings
6
+
7
+
8
+ class ConfigurationLucyAssist(LucyAssistBaseModel):
9
+ """
10
+ Configuration singleton pour Lucy Assist.
11
+ Gère les tokens disponibles et les paramètres globaux.
12
+ """
13
+ tokens_disponibles = models.BigIntegerField(default=0)
14
+ prix_par_million_tokens = models.DecimalField(
15
+ max_digits=10,
16
+ decimal_places=2,
17
+ default=lucy_assist_settings.PRIX_PAR_MILLION_TOKENS
18
+ )
19
+ questions_frequentes = models.JSONField(default=list, blank=True)
20
+ actif = models.BooleanField(default=True)
21
+ avatar = models.ImageField(null=True, blank=True)
22
+
23
+ # Mapping des modèles vers leurs applications Django
24
+ model_app_mapping = models.JSONField(blank=True, default=dict)
25
+
26
+ class Meta:
27
+ verbose_name = "Configuration Lucy Assist"
28
+ verbose_name_plural = "Configuration Lucy Assist"
29
+
30
+ def __str__(self):
31
+ return f"Configuration Lucy Assist - {self.tokens_disponibles:,} tokens"
32
+
33
+ @classmethod
34
+ def get_config(cls):
35
+ """
36
+ Retourne la configuration singleton.
37
+ Utilise le cache pour optimiser les performances.
38
+ """
39
+ cache_key = 'lucy_assist_config'
40
+ config = cache.get(cache_key)
41
+
42
+ if config is None:
43
+ config, _ = cls.objects.get_or_create(pk=1)
44
+ cache.set(cache_key, config, timeout=300) # Cache 5 minutes
45
+
46
+ return config
47
+
48
+ def save(self, *args, **kwargs):
49
+ # Forcer l'ID à 1 pour le singleton
50
+ self.pk = 1
51
+ super().save(*args, **kwargs)
52
+ # Invalider le cache
53
+ cache.delete('lucy_assist_config')
54
+
55
+ @property
56
+ def tokens_restants_en_euros(self):
57
+ """Retourne la valeur en euros des tokens restants."""
58
+ return (self.tokens_disponibles / 1_000_000) * float(self.prix_par_million_tokens)
59
+
60
+ @property
61
+ def conversations_estimees(self):
62
+ """Estime le nombre de conversations possibles avec les tokens restants."""
63
+ return int(self.tokens_disponibles / lucy_assist_settings.TOKENS_MOYENS_PAR_CONVERSATION)
64
+
65
+ def ajouter_tokens(self, montant_euros):
66
+ """
67
+ Ajoute des tokens en fonction d'un montant en euros.
68
+ Retourne le nombre de tokens ajoutés.
69
+ """
70
+ tokens_a_ajouter = int((montant_euros / float(self.prix_par_million_tokens)) * 1_000_000)
71
+ self.tokens_disponibles += tokens_a_ajouter
72
+ self.save(update_fields=['tokens_disponibles'])
73
+ return tokens_a_ajouter
74
+
75
+ def a_suffisamment_tokens(self, tokens_requis=2000):
76
+ """Vérifie si suffisamment de tokens sont disponibles."""
77
+ return self.tokens_disponibles >= tokens_requis
78
+
79
+ def get_questions_frequentes(self):
80
+ """
81
+ Retourne les questions fréquentes configurées.
82
+ Si aucune n'est configurée, retourne les questions par défaut.
83
+ """
84
+ if self.questions_frequentes:
85
+ return self.questions_frequentes
86
+
87
+ # Questions par défaut si aucune n'est configurée
88
+ return lucy_assist_settings.QUESTIONS_FREQUENTES_DEFAULT
89
+
90
+ def get_app_for_model(self, model_name: str) -> str:
91
+ """
92
+ Retourne le nom de l'application Django pour un modèle donné.
93
+
94
+ Args:
95
+ model_name: Nom du modèle (insensible à la casse)
96
+
97
+ Returns:
98
+ Nom de l'application Django, ou le nom du modèle en minuscules si non trouvé
99
+ """
100
+ # Si le mapping est vide, le construire dynamiquement depuis les modèles Django
101
+ if not self.model_app_mapping:
102
+ self.model_app_mapping = self._build_model_app_mapping()
103
+ self.save(update_fields=['model_app_mapping'])
104
+ # Invalider le cache pour que les prochains appels utilisent la nouvelle valeur
105
+ cache.delete('lucy_assist_config')
106
+
107
+ model_lower = model_name.lower()
108
+ return self.model_app_mapping.get(model_lower, model_lower)
109
+
110
+ def _build_model_app_mapping(self) -> dict:
111
+ """
112
+ Construit dynamiquement le mapping modèle -> application
113
+ en parcourant tous les modèles Django enregistrés.
114
+
115
+ Returns:
116
+ Dict avec les noms de modèles en minuscules comme clés
117
+ et les noms d'applications comme valeurs.
118
+ """
119
+ from django.apps import apps
120
+
121
+ mapping = {}
122
+ apps_prefix = lucy_assist_settings.PROJECT_APPS_PREFIX
123
+
124
+ for app_config in apps.get_app_configs():
125
+ app_label = app_config.label
126
+
127
+ # Filtrer les apps si un préfixe est configuré
128
+ if apps_prefix and not app_config.name.startswith(apps_prefix):
129
+ continue
130
+
131
+ # Utiliser le label de l'app
132
+ app_name = app_label
133
+
134
+ for model in app_config.get_models():
135
+ model_name_lower = model.__name__.lower()
136
+ # Ne pas écraser si déjà présent (priorité au premier trouvé)
137
+ if model_name_lower not in mapping:
138
+ mapping[model_name_lower] = app_name
139
+
140
+ return mapping
141
+
142
+ def update_model_mapping(self, model_name: str, app_name: str) -> None:
143
+ """
144
+ Met à jour le mapping pour un modèle spécifique.
145
+ Utilisé quand le chatbot découvre un nouveau mapping.
146
+
147
+ Args:
148
+ model_name: Nom du modèle
149
+ app_name: Nom de l'application Django
150
+ """
151
+ if not self.model_app_mapping:
152
+ self.model_app_mapping = {}
153
+
154
+ model_lower = model_name.lower()
155
+ self.model_app_mapping[model_lower] = app_name
156
+ self.save(update_fields=['model_app_mapping'])
157
+ cache.delete('lucy_assist_config')
158
+
159
+ @classmethod
160
+ def get_app_for_model_static(cls, model_name: str) -> str:
161
+ """
162
+ Version statique pour récupérer l'app d'un modèle.
163
+ Utilise la config en cache.
164
+ """
165
+ config = cls.get_config()
166
+ return config.get_app_for_model(model_name)
167
+
168
+
169
+ def get_default_model_app_mapping() -> dict:
170
+ """
171
+ Fonction utilisée par la migration pour la valeur par défaut.
172
+ Retourne un dict vide car le mapping est maintenant construit
173
+ dynamiquement lors du premier accès.
174
+ """
175
+ return {}
@@ -0,0 +1,54 @@
1
+ from django.conf import settings
2
+ from django.db import models
3
+
4
+ from lucy_assist.models.base import LucyAssistBaseModel
5
+
6
+
7
+ class Conversation(LucyAssistBaseModel):
8
+ """
9
+ Modèle représentant une conversation entre un utilisateur et Lucy Assist.
10
+ """
11
+ titre = models.CharField(max_length=255, blank=True, null=True)
12
+ page_contexte = models.CharField(max_length=500, blank=True, null=True)
13
+
14
+ # ForeignKey
15
+ utilisateur = models.ForeignKey(
16
+ settings.AUTH_USER_MODEL,
17
+ on_delete=models.CASCADE,
18
+ related_name='lucy_assist_conversations'
19
+ )
20
+
21
+ class Meta:
22
+ verbose_name = "Conversation Lucy Assist"
23
+ verbose_name_plural = "Conversations Lucy Assist"
24
+ ordering = ['-created_date']
25
+
26
+ def __str__(self):
27
+ user_str = getattr(self.utilisateur, 'email', str(self.utilisateur))
28
+ return f"Conversation #{self.pk} - {user_str}"
29
+
30
+ @property
31
+ def total_tokens(self):
32
+ """Retourne le nombre total de tokens utilisés dans cette conversation."""
33
+ return self.messages.aggregate(
34
+ total=models.Sum('tokens_utilises')
35
+ )['total'] or 0
36
+
37
+ @property
38
+ def dernier_message(self):
39
+ """Retourne le dernier message de la conversation."""
40
+ return self.messages.order_by('-created_date').first()
41
+
42
+ def generer_titre(self):
43
+ """Génère un titre automatique basé sur le premier message utilisateur."""
44
+ premier_message = self.messages.filter(
45
+ repondant='UTILISATEUR'
46
+ ).order_by('created_date').first()
47
+
48
+ if premier_message:
49
+ # Tronquer le message pour créer un titre
50
+ contenu = premier_message.contenu[:50]
51
+ if len(premier_message.contenu) > 50:
52
+ contenu += "..."
53
+ self.titre = contenu
54
+ self.save(update_fields=['titre'])
@@ -0,0 +1,45 @@
1
+ from django.db import models
2
+
3
+ from lucy_assist.models.base import LucyAssistBaseModel
4
+ from lucy_assist.constantes import LucyAssistConstantes
5
+
6
+
7
+ class Message(LucyAssistBaseModel):
8
+ """
9
+ Modèle représentant un message dans une conversation Lucy Assist.
10
+ """
11
+ repondant = models.CharField(max_length=20, choices=LucyAssistConstantes.Repondant.tuples)
12
+ contenu = models.TextField()
13
+ tokens_utilises = models.IntegerField(default=0)
14
+ type_action = models.CharField(
15
+ max_length=50,
16
+ choices=LucyAssistConstantes.TypeAction.tuples,
17
+ blank=True,
18
+ null=True
19
+ )
20
+ metadata = models.JSONField(default=dict, blank=True) # Données supplémentaires (contexte, erreurs, etc.)
21
+
22
+ # ForeignKey
23
+ conversation = models.ForeignKey(
24
+ 'lucy_assist.Conversation',
25
+ on_delete=models.CASCADE,
26
+ related_name='messages'
27
+ )
28
+
29
+ class Meta:
30
+ verbose_name = "Message Lucy Assist"
31
+ verbose_name_plural = "Messages Lucy Assist"
32
+ ordering = ['created_date']
33
+
34
+ def __str__(self):
35
+ return f"Message #{self.pk} - {self.get_repondant_display()}"
36
+
37
+ @property
38
+ def est_chatbot(self):
39
+ """Retourne True si le message vient du chatbot."""
40
+ return self.repondant == LucyAssistConstantes.Repondant.CHATBOT
41
+
42
+ @property
43
+ def est_utilisateur(self):
44
+ """Retourne True si le message vient de l'utilisateur."""
45
+ return self.repondant == LucyAssistConstantes.Repondant.UTILISATEUR
@@ -0,0 +1,213 @@
1
+ """
2
+ Cache du contexte projet pour optimiser les tokens Claude.
3
+ """
4
+ import hashlib
5
+ import json
6
+ from datetime import timedelta
7
+ from typing import Optional, Dict, Any
8
+
9
+ from django.db import models
10
+ from django.core.cache import cache
11
+ from django.utils import timezone
12
+
13
+ from lucy_assist.models.base import LucyAssistBaseModel
14
+
15
+
16
+ class ProjectContextCache(LucyAssistBaseModel):
17
+ """
18
+ Cache du contexte projet analysé.
19
+
20
+ Stocke les informations sur la structure du projet pour éviter
21
+ de re-analyser GitLab à chaque requête Claude.
22
+ """
23
+ cache_key = models.CharField(
24
+ max_length=255,
25
+ unique=True,
26
+ db_index=True
27
+ ) # Clé unique pour identifier le type de contexte
28
+ contenu = models.JSONField(default=dict) # Contenu mis en cache (JSON)
29
+ content_hash = models.CharField(max_length=64, blank=True) # Hash du contenu source pour détecter les changements
30
+ expire_at = models.DateTimeField()
31
+ tokens_economises = models.BigIntegerField(default=0) # Nombre de tokens économisés grâce au cache
32
+ hit_count = models.IntegerField(default=0) # Nombre d'utilisations
33
+
34
+ class Meta:
35
+ verbose_name = "Cache contexte projet"
36
+ verbose_name_plural = "Caches contexte projet"
37
+
38
+ def __str__(self):
39
+ return f"Cache: {self.cache_key}"
40
+
41
+ @property
42
+ def is_expired(self) -> bool:
43
+ """Vérifie si le cache est expiré."""
44
+ return timezone.now() > self.expire_at
45
+
46
+ @property
47
+ def is_valid(self) -> bool:
48
+ """Vérifie si le cache est valide (non expiré)."""
49
+ return not self.is_expired
50
+
51
+ def increment_hit(self, tokens_saved: int = 0):
52
+ """Incrémente le compteur d'utilisation."""
53
+ self.hit_count += 1
54
+ self.tokens_economises += tokens_saved
55
+ self.save(update_fields=['hit_count', 'tokens_economises'])
56
+
57
+ @classmethod
58
+ def get_or_create_cache(
59
+ cls,
60
+ cache_key: str,
61
+ ttl_hours: int = 24
62
+ ) -> 'ProjectContextCache':
63
+ """
64
+ Récupère ou crée un cache.
65
+
66
+ Args:
67
+ cache_key: Clé unique du cache
68
+ ttl_hours: Durée de vie en heures
69
+
70
+ Returns:
71
+ Instance de ProjectContextCache
72
+ """
73
+ expire_at = timezone.now() + timedelta(hours=ttl_hours)
74
+
75
+ cache_obj, created = cls.objects.get_or_create(
76
+ cache_key=cache_key,
77
+ defaults={
78
+ 'expire_at': expire_at,
79
+ 'contenu': {}
80
+ }
81
+ )
82
+
83
+ # Renouveler si expiré
84
+ if cache_obj.is_expired:
85
+ cache_obj.expire_at = expire_at
86
+ cache_obj.contenu = {}
87
+ cache_obj.content_hash = ''
88
+ cache_obj.save()
89
+
90
+ return cache_obj
91
+
92
+ @classmethod
93
+ def get_cached_content(
94
+ cls,
95
+ cache_key: str,
96
+ content_hash: Optional[str] = None
97
+ ) -> Optional[Dict]:
98
+ """
99
+ Récupère le contenu mis en cache si valide.
100
+
101
+ Args:
102
+ cache_key: Clé du cache
103
+ content_hash: Hash pour vérifier si le contenu a changé
104
+
105
+ Returns:
106
+ Contenu caché ou None si invalide/expiré
107
+ """
108
+ # D'abord vérifier le cache Django (plus rapide)
109
+ django_cache_key = f"lucy_assist_context_{cache_key}"
110
+ cached = cache.get(django_cache_key)
111
+ if cached:
112
+ return cached
113
+
114
+ try:
115
+ cache_obj = cls.objects.get(cache_key=cache_key)
116
+
117
+ # Vérifier l'expiration
118
+ if cache_obj.is_expired:
119
+ return None
120
+
121
+ # Vérifier le hash si fourni
122
+ if content_hash and cache_obj.content_hash != content_hash:
123
+ return None
124
+
125
+ # Mettre en cache Django pour accès rapide
126
+ cache.set(django_cache_key, cache_obj.contenu, timeout=3600)
127
+
128
+ return cache_obj.contenu
129
+
130
+ except cls.DoesNotExist:
131
+ return None
132
+
133
+ @classmethod
134
+ def set_cached_content(
135
+ cls,
136
+ cache_key: str,
137
+ contenu: Dict,
138
+ content_hash: str = '',
139
+ ttl_hours: int = 24
140
+ ) -> 'ProjectContextCache':
141
+ """
142
+ Stocke le contenu en cache.
143
+
144
+ Args:
145
+ cache_key: Clé du cache
146
+ contenu: Contenu à cacher
147
+ content_hash: Hash du contenu source
148
+ ttl_hours: Durée de vie en heures
149
+
150
+ Returns:
151
+ Instance de ProjectContextCache
152
+ """
153
+ expire_at = timezone.now() + timedelta(hours=ttl_hours)
154
+
155
+ cache_obj, _ = cls.objects.update_or_create(
156
+ cache_key=cache_key,
157
+ defaults={
158
+ 'contenu': contenu,
159
+ 'content_hash': content_hash,
160
+ 'expire_at': expire_at
161
+ }
162
+ )
163
+
164
+ # Mettre aussi en cache Django
165
+ django_cache_key = f"lucy_assist_context_{cache_key}"
166
+ cache.set(django_cache_key, contenu, timeout=ttl_hours * 3600)
167
+
168
+ return cache_obj
169
+
170
+ @staticmethod
171
+ def compute_hash(content: Any) -> str:
172
+ """Calcule le hash d'un contenu."""
173
+ if isinstance(content, dict) or isinstance(content, list):
174
+ content = json.dumps(content, sort_keys=True)
175
+ return hashlib.sha256(str(content).encode()).hexdigest()[:16]
176
+
177
+ @classmethod
178
+ def invalidate_cache(cls, cache_key: str):
179
+ """Invalide un cache spécifique."""
180
+ # Supprimer du cache Django
181
+ django_cache_key = f"lucy_assist_context_{cache_key}"
182
+ cache.delete(django_cache_key)
183
+
184
+ # Supprimer de la BDD
185
+ cls.objects.filter(cache_key=cache_key).delete()
186
+
187
+ @classmethod
188
+ def invalidate_all(cls):
189
+ """Invalide tous les caches Lucy Assist."""
190
+ # Supprimer tous les caches (la suppression du cache Django pattern n'est pas disponible)
191
+ cls.objects.all().delete()
192
+
193
+ @classmethod
194
+ def get_stats(cls) -> Dict:
195
+ """Retourne les statistiques des caches."""
196
+ from django.db.models import Sum, Count
197
+
198
+ stats = cls.objects.aggregate(
199
+ total_caches=Count('id'),
200
+ total_hits=Sum('hit_count'),
201
+ total_tokens_saved=Sum('tokens_economises')
202
+ )
203
+
204
+ active_caches = cls.objects.filter(
205
+ expire_at__gt=timezone.now()
206
+ ).count()
207
+
208
+ return {
209
+ 'total_caches': stats['total_caches'] or 0,
210
+ 'active_caches': active_caches,
211
+ 'total_hits': stats['total_hits'] or 0,
212
+ 'total_tokens_saved': stats['total_tokens_saved'] or 0
213
+ }
@@ -0,0 +1,21 @@
1
+ from .claude_service import ClaudeService
2
+ from .gitlab_service import GitLabService
3
+ from .context_service import ContextService
4
+ from .crud_service import CRUDService
5
+ from .project_context_service import ProjectContextService
6
+ from .tool_executor_service import ToolExecutorService, create_tool_executor
7
+ from .tools_definition import LUCY_ASSIST_TOOLS, get_app_for_model
8
+ from .bug_notification_service import BugNotificationService
9
+
10
+ __all__ = [
11
+ 'ClaudeService',
12
+ 'GitLabService',
13
+ 'ContextService',
14
+ 'CRUDService',
15
+ 'ProjectContextService',
16
+ 'ToolExecutorService',
17
+ 'create_tool_executor',
18
+ 'LUCY_ASSIST_TOOLS',
19
+ 'get_app_for_model',
20
+ 'BugNotificationService',
21
+ ]