Files
flowsight/backend/internal/llm/llm.go
T
asepharyana 8c184ccae1 feat: add Citations and WatchlistChat components, integrate with API
- Implemented Citations component to display citation data.
- Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality.
- Integrated API calls for watchlist management and chat interactions.
- Updated index.tsx to include new components in the main application layout.
- Added API client in lib/api.ts for structured API interactions.
- Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components.
- Introduced styles in tokens.css for consistent theming across the application.
- Configured TypeScript and Vite for project setup and development.
2026-09-15 12:36:48 +07:00

123 lines
3.3 KiB
Go

// Package llm calls an OpenAI-compatible chat endpoint for synthesis, report
// interrogation, and sentiment triage. Two models: LLM_MODEL_TRIAGE (cheap)
// and LLM_MODEL_SYNTH (strong), both overridden via env. No LLM call is on
// the critical detection path — rules and scores are computed locally first.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Client talks to an OpenAI-compatible /chat/completions endpoint.
type Client struct {
baseURL string
apiKey string
http *http.Client
}
// New builds a client; baseURL is like https://api.openai.com/v1.
func New(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Available reports whether LLM calls are configured.
func (c *Client) Available() bool { return c.baseURL != "" && c.apiKey != "" }
type chatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Complete sends one chat completion and returns the text content.
func (c *Client) Complete(ctx context.Context, model, system, user string, maxTokens int) (string, error) {
if !c.Available() {
return "", fmt.Errorf("llm: LLM_BASE_URL/LLM_API_KEY not configured")
}
if maxTokens <= 0 {
maxTokens = 800
}
body, _ := json.Marshal(map[string]any{
"model": model,
"messages": []chatMsg{{Role: "system", Content: system}, {Role: "user", Content: user}},
"max_tokens": maxTokens,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("llm: %w", err)
}
defer resp.Body.Close()
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("llm: decode: %w", err)
}
if out.Error != nil {
return "", fmt.Errorf("llm: %s", out.Error.Message)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("llm: empty response")
}
return out.Choices[0].Message.Content, nil
}
// SentimentTriage classifies one article; falls back to neutral on any error
// so sentiment never blocks the pipeline.
func (c *Client) SentimentTriage(ctx context.Context, model, title, body string) (string, float64) {
if !c.Available() {
return "neutral", 0.5
}
text, err := c.Complete(ctx, model,
`Classify Indonesian stock news as bullish, bearish, or neutral. Reply with exactly: <label> <confidence 0-1>. No other text.`,
"Title: "+title+"\nBody: "+head(body, 1500), 20)
if err != nil {
return "neutral", 0.5
}
parts := strings.Fields(strings.ToLower(text))
if len(parts) == 0 {
return "neutral", 0.5
}
label := parts[0]
if label != "bullish" && label != "bearish" {
label = "neutral"
}
var conf float64 = 0.6
if len(parts) > 1 {
fmt.Sscanf(parts[1], "%f", &conf)
}
if conf < 0 || conf > 1 {
conf = 0.6
}
return label, conf
}
func head(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}