package chat import ( "context" "fmt" "log/slog" "strings" "trankilou.fr/lassistanoque/backend/internal/domain" ) const ( // charsPerToken : estimation heuristique, ~4 caractères par token pour du // texte européen. Suffisant pour décider d'une compaction. charsPerToken = 4 // defaultContextMaxTokens : taille de contexte du modèle quand la // configuration ne la précise pas. defaultContextMaxTokens = 32000 // defaultMaxResponseTokens : réserve à ménager pour la réponse du modèle. defaultMaxResponseTokens = 4096 ) // estimateTokens estime le nombre de tokens d'un texte. func estimateTokens(text string) int { if text == "" { return 0 } tokens := len(text) / charsPerToken if len(text)%charsPerToken > 0 { tokens++ } return tokens } // contextTokens estime la taille en tokens du prompt complet envoyé au LLM. func (s *ChatSession) contextTokens(systemPrompt string) int { total := estimateTokens(systemPrompt) for _, m := range s.messages { total += estimateTokens(m.Content) + estimateTokens(m.ToolCallsJson) } return total } // shouldCompact indique si la taille du contexte a atteint la limite au-delà // de laquelle il ne reste plus assez de place pour une réponse complète : // contexte max diminué de la taille de réponse max. func (s *ChatSession) shouldCompact(systemPrompt string) bool { return s.contextTokens(systemPrompt) >= s.contextMaxTokens-s.maxResponseTokens } // compactContext remplace l'historique ancien par un résumé généré par le LLM. // Les échanges récents (à partir du dernier message utilisateur) sont conservés // intacts : un point de coupure sur un message utilisateur ne peut jamais // séparer un message assistant porteur d'appels d'outils de ses réponses tool, // ce qui garderait un historique incohérent pour l'API. // La compaction ne porte que sur la session en mémoire : l'historique complet // reste intact en base. func (s *ChatSession) compactContext(ctx context.Context) { cut := -1 for i := len(s.messages) - 1; i >= 0; i-- { if s.messages[i].Role == string(domain.RoleUser) { cut = i break } } // rien à compacter : l'historique commence déjà au dernier message utilisateur if cut <= 0 { return } head := s.messages[:cut] tail := s.messages[cut:] var sb strings.Builder for _, m := range head { fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) } summary, err := s.llmEngine.Generate(ctx, s.provider, s.modelID, []*domain.Message{ { Role: string(domain.RoleUser), Content: "Résume de façon concise et factuelle la conversation suivante. " + "Conserve les informations essentielles : demandes, décisions, résultats, fichiers ou liens mentionnés. " + "Réponds en texte brut, sans markdown.\n\n" + sb.String(), }, }) if err != nil { slog.Error("compactContext.Generate", "error", err) return } summaryMessage := &domain.Message{ Role: string(domain.RoleUser), Content: "Résumé de la conversation précédente (les messages plus anciens ont été compactés) :\n" + summary, } s.messages = append([]*domain.Message{summaryMessage}, tail...) slog.Info("context compacted", "removedMessages", len(head), "keptMessages", len(tail)+1, ) }