Files
Tradarr/backend/internal/ai/pipeline.go

232 lines
6.9 KiB
Go

package ai
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/tradarr/backend/internal/crypto"
"github.com/tradarr/backend/internal/models"
)
const DefaultSystemPrompt = `Tu es un assistant spécialisé en trading financier. Analyse l'ensemble des actualités suivantes, toutes sources confondues, et crée un résumé global structuré en français, orienté trading.
Structure ton résumé ainsi :
1. **Vue macro** : tendances globales du marché (économie, géopolitique, secteurs)
2. **Actifs surveillés** : pour chaque actif de la watchlist mentionné dans les news :
- Sentiment (haussier/baissier/neutre)
- Faits clés et catalyseurs
- Risques et opportunités
3. **Autres mouvements notables** : actifs hors watchlist à surveiller
4. **Synthèse** : points d'attention prioritaires pour la journée`
type Pipeline struct {
repo *models.Repository
enc *crypto.Encryptor
}
func NewPipeline(repo *models.Repository, enc *crypto.Encryptor) *Pipeline {
return &Pipeline{repo: repo, enc: enc}
}
func (p *Pipeline) BuildProvider(name, apiKey, endpoint string) (Provider, error) {
provider, err := p.repo.GetActiveAIProvider()
if err != nil {
return nil, err
}
model := ""
if provider != nil {
model = provider.Model
}
return NewProvider(name, apiKey, model, endpoint)
}
func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models.Summary, error) {
providerCfg, err := p.repo.GetActiveAIProvider()
if err != nil {
return nil, fmt.Errorf("get active provider: %w", err)
}
if providerCfg == nil {
return nil, fmt.Errorf("no active AI provider configured")
}
apiKey := ""
if providerCfg.APIKeyEncrypted != "" {
apiKey, err = p.enc.Decrypt(providerCfg.APIKeyEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypt API key: %w", err)
}
}
provider, err := NewProvider(providerCfg.Name, apiKey, providerCfg.Model, providerCfg.Endpoint)
if err != nil {
return nil, fmt.Errorf("build provider: %w", err)
}
assets, err := p.repo.GetUserAssets(userID)
if err != nil {
return nil, fmt.Errorf("get user assets: %w", err)
}
symbols := make([]string, len(assets))
for i, a := range assets {
symbols[i] = a.Symbol
}
hoursStr, _ := p.repo.GetSetting("articles_lookback_hours")
hours, _ := strconv.Atoi(hoursStr)
if hours == 0 {
hours = 24
}
articles, err := p.repo.GetRecentArticles(hours)
if err != nil {
return nil, fmt.Errorf("get articles: %w", err)
}
if len(articles) == 0 {
return nil, fmt.Errorf("no recent articles found")
}
maxStr, _ := p.repo.GetSetting("summary_max_articles")
maxArticles, _ := strconv.Atoi(maxStr)
if maxArticles == 0 {
maxArticles = 50
}
// Passe 1 : filtrage par pertinence sur les titres si trop d'articles
if len(articles) > maxArticles {
fmt.Printf("pipeline: %d articles → filtering to %d via AI\n", len(articles), maxArticles)
articles = p.filterByRelevance(ctx, provider, symbols, articles, maxArticles)
fmt.Printf("pipeline: %d articles retained after filtering\n", len(articles))
}
systemPrompt, _ := p.repo.GetSetting("ai_system_prompt")
if systemPrompt == "" {
systemPrompt = DefaultSystemPrompt
}
// Passe 2 : résumé complet
prompt := buildPrompt(systemPrompt, symbols, articles)
summary, err := provider.Summarize(ctx, prompt)
if err != nil {
return nil, fmt.Errorf("AI summarize: %w", err)
}
return p.repo.CreateSummary(userID, summary, &providerCfg.ID)
}
// filterByRelevance demande à l'IA de sélectionner les articles les plus pertinents
// en ne lui envoyant que les titres (prompt très court = rapide).
func (p *Pipeline) filterByRelevance(ctx context.Context, provider Provider, symbols []string, articles []models.Article, max int) []models.Article {
prompt := buildFilterPrompt(symbols, articles, max)
response, err := provider.Summarize(ctx, prompt)
if err != nil {
fmt.Printf("pipeline: filter AI call failed (%v), falling back to truncation\n", err)
return articles[:max]
}
indices := parseIndexArray(response, len(articles))
if len(indices) == 0 {
fmt.Printf("pipeline: could not parse filter response, falling back to truncation\n")
return articles[:max]
}
filtered := make([]models.Article, 0, len(indices))
for _, i := range indices {
filtered = append(filtered, articles[i])
if len(filtered) >= max {
break
}
}
return filtered
}
func buildFilterPrompt(symbols []string, articles []models.Article, max int) string {
var sb strings.Builder
sb.WriteString("Tu es un assistant de trading financier. ")
sb.WriteString(fmt.Sprintf("Parmi les %d articles ci-dessous, sélectionne les %d plus pertinents pour un trader actif.\n", len(articles), max))
if len(symbols) > 0 {
sb.WriteString("Actifs surveillés (priorité haute) : ")
sb.WriteString(strings.Join(symbols, ", "))
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("\nRéponds UNIQUEMENT avec un tableau JSON des indices sélectionnés (base 0), exemple : [0, 3, 7, 12]\n"))
sb.WriteString("N'ajoute aucun texte avant ou après le tableau JSON.\n\n")
sb.WriteString("Articles :\n")
for i, a := range articles {
sb.WriteString(fmt.Sprintf("[%d] %s (%s)\n", i, a.Title, a.SourceName))
}
return sb.String()
}
var jsonArrayRe = regexp.MustCompile(`\[[\d\s,]+\]`)
func parseIndexArray(response string, maxIndex int) []int {
match := jsonArrayRe.FindString(response)
if match == "" {
return nil
}
match = strings.Trim(match, "[]")
parts := strings.Split(match, ",")
seen := make(map[int]bool)
var indices []int
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil || n < 0 || n >= maxIndex || seen[n] {
continue
}
seen[n] = true
indices = append(indices, n)
}
return indices
}
func (p *Pipeline) GenerateForAll(ctx context.Context) error {
users, err := p.repo.ListUsers()
if err != nil {
return err
}
for _, user := range users {
if _, err := p.GenerateForUser(ctx, user.ID); err != nil {
fmt.Printf("summary for user %s: %v\n", user.Email, err)
}
}
return nil
}
func buildPrompt(systemPrompt string, symbols []string, articles []models.Article) string {
var sb strings.Builder
sb.WriteString(systemPrompt)
sb.WriteString("\n\n")
if len(symbols) > 0 {
sb.WriteString("Le trader surveille particulièrement ces actifs (sois attentif à toute mention) : ")
sb.WriteString(strings.Join(symbols, ", "))
sb.WriteString(".\n\n")
}
sb.WriteString(fmt.Sprintf("Date d'analyse : %s\n\n", time.Now().Format("02/01/2006 15:04")))
sb.WriteString("## Actualités\n\n")
for i, a := range articles {
sb.WriteString(fmt.Sprintf("### [%d] %s\n", i+1, a.Title))
sb.WriteString(fmt.Sprintf("Source : %s\n", a.SourceName))
if a.PublishedAt.Valid {
sb.WriteString(fmt.Sprintf("Date : %s\n", a.PublishedAt.Time.Format("02/01/2006 15:04")))
}
content := a.Content
if len(content) > 1000 {
content = content[:1000] + "..."
}
sb.WriteString(content)
sb.WriteString("\n\n")
}
return sb.String()
}