Files
aitrade/pkg/app/news/llm_scorer.go
T
kaedwen ea141eb012
Build and Push Docker Image / build-and-push (push) Failing after 1m41s
initial
2026-07-02 20:09:44 +02:00

251 lines
6.9 KiB
Go

package news
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/pheinrich/aitrade/pkg/config"
"github.com/pheinrich/aitrade/pkg/model"
)
// LLMScorer uses a local LLM (via Ollama) for contextual sentiment analysis
type LLMScorer struct {
cfg *config.LLMScorerConfig
httpClient *http.Client
fallbackAnalyzer *Analyzer
logger *slog.Logger
}
// LLMRequest represents the Ollama API request
type LLMRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
}
// LLMResponse represents the Ollama API response
type LLMResponse struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
Response string `json:"response"`
Done bool `json:"done"`
}
// SentimentResponse represents parsed sentiment from LLM
type SentimentResponse struct {
Sentiment string `json:"sentiment"` // "positive", "negative", "neutral"
Score float64 `json:"score"` // -1.0 to 1.0
Confidence float64 `json:"confidence"` // 0.0 to 1.0
Reasoning string `json:"reasoning"`
}
func NewLLMScorer(
cfg *config.LLMScorerConfig,
fallbackAnalyzer *Analyzer,
logger *slog.Logger,
) *LLMScorer {
return &LLMScorer{
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.Timeout.Duration},
fallbackAnalyzer: fallbackAnalyzer,
logger: logger,
}
}
func (l *LLMScorer) Enabled() bool {
return l.cfg.Enabled
}
// Analyze uses LLM to analyze sentiment with optional ensemble mode
func (l *LLMScorer) Analyze(ctx context.Context, article *model.NewsArticle) error {
if !l.cfg.Enabled {
return nil
}
// Build structured prompt
prompt := l.buildPrompt(article)
// Call Ollama API
sentimentResp, err := l.callOllamaAPI(ctx, prompt)
if err != nil {
l.logger.Warn("LLM scoring failed, using keyword fallback",
slog.String("article_id", fmt.Sprintf("%d", article.ID)),
slog.Any("error", err))
// Fallback to keyword analyzer
l.fallbackAnalyzer.Analyze(article)
article.SentimentMethod = "keyword_fallback"
return nil
}
// Validate sentiment score
if sentimentResp.Score < -1.0 || sentimentResp.Score > 1.0 {
l.logger.Warn("LLM returned invalid score, using keyword fallback",
slog.Float64("score", sentimentResp.Score))
l.fallbackAnalyzer.Analyze(article)
article.SentimentMethod = "keyword_fallback"
return nil
}
// Ensemble mode: weighted average of LLM + keyword scores
if l.cfg.EnsembleWeight < 1.0 && l.fallbackAnalyzer != nil {
// Get keyword score
keywordArticle := &model.NewsArticle{
Title: article.Title,
Content: article.Content,
}
l.fallbackAnalyzer.Analyze(keywordArticle)
if keywordArticle.SentimentScore != nil {
keywordScore := *keywordArticle.SentimentScore
finalScore := (sentimentResp.Score * l.cfg.EnsembleWeight) +
(keywordScore * (1.0 - l.cfg.EnsembleWeight))
article.SentimentScore = &finalScore
article.SentimentMethod = "ensemble"
l.logger.Debug("ensemble scoring",
slog.Float64("llm_score", sentimentResp.Score),
slog.Float64("keyword_score", keywordScore),
slog.Float64("final_score", finalScore),
slog.Float64("weight", l.cfg.EnsembleWeight))
} else {
article.SentimentScore = &sentimentResp.Score
article.SentimentMethod = "llm"
}
} else {
// LLM only
article.SentimentScore = &sentimentResp.Score
article.SentimentMethod = "llm"
}
// Set sentiment label based on final score
if article.SentimentScore != nil {
score := *article.SentimentScore
if score > 0.3 {
article.SentimentLabel = "positive"
} else if score < -0.3 {
article.SentimentLabel = "negative"
} else {
article.SentimentLabel = "neutral"
}
}
// Store LLM metadata
article.LLMModel = &l.cfg.ModelName
article.LLMConfidence = &sentimentResp.Confidence
return nil
}
// buildPrompt creates a structured prompt for financial sentiment analysis
func (l *LLMScorer) buildPrompt(article *model.NewsArticle) string {
return fmt.Sprintf(`You are a financial sentiment analyzer. Respond ONLY with valid JSON (no markdown, no explanation).
Analyze this news article and determine if it's positive, negative, or neutral for stock trading:
---
Title: %s
Content: %s
---
Respond with JSON in this exact format:
{
"sentiment": "positive" | "negative" | "neutral",
"score": <float between -1.0 and 1.0>,
"confidence": <float between 0.0 and 1.0>,
"reasoning": "<brief one-sentence explanation>"
}
JSON Response:`, article.Title, article.Content)
}
// callOllamaAPI sends request to Ollama API and parses response
func (l *LLMScorer) callOllamaAPI(ctx context.Context, prompt string) (*SentimentResponse, error) {
// Build request
reqBody := LLMRequest{
Model: l.cfg.ModelName,
Prompt: prompt,
Temperature: l.cfg.Temperature,
Stream: false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create HTTP request with context
req, err := http.NewRequestWithContext(ctx, "POST", l.cfg.Endpoint+"/api/generate", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Send request
resp, err := l.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call Ollama API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Ollama API returned status %d: %s", resp.StatusCode, string(body))
}
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var llmResp LLMResponse
if err := json.Unmarshal(body, &llmResp); err != nil {
return nil, fmt.Errorf("failed to parse Ollama response: %w", err)
}
// Parse sentiment from LLM response
return l.parseSentiment(llmResp.Response)
}
// parseSentiment extracts structured sentiment from LLM text response
func (l *LLMScorer) parseSentiment(response string) (*SentimentResponse, error) {
// Try to find JSON in response (LLM might add text before/after)
startIdx := -1
endIdx := -1
for i := 0; i < len(response); i++ {
if response[i] == '{' && startIdx == -1 {
startIdx = i
}
if response[i] == '}' {
endIdx = i + 1
}
}
if startIdx == -1 || endIdx == -1 {
return nil, fmt.Errorf("no JSON found in LLM response")
}
jsonStr := response[startIdx:endIdx]
var sentiment SentimentResponse
if err := json.Unmarshal([]byte(jsonStr), &sentiment); err != nil {
return nil, fmt.Errorf("failed to parse sentiment JSON: %w", err)
}
// Validate sentiment field
if sentiment.Sentiment != "positive" && sentiment.Sentiment != "negative" && sentiment.Sentiment != "neutral" {
return nil, fmt.Errorf("invalid sentiment value: %s", sentiment.Sentiment)
}
return &sentiment, nil
}