251 lines
6.9 KiB
Go
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
|
|
}
|