Files
2026-09-11 09:54:49 +08:00

88 lines
3.0 KiB
Go

package analyze
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"sync"
"gitea.konchin.com/service/stonks/internal/config"
)
// AdvisorPreamble is prepended to the shared data prompt when consulting an
// advisor CLI (multi-agent mode) — advisors get a simpler ask than Claude's
// SystemPrompt: an independent take, no recommendations-JSON contract, and
// deliberately no amount/leverage/portfolio framing. Advisor output is
// plain-text commentary only (AdvisorResult.Output); it is folded into
// Claude's prompt by AppendAdvisorSection as opinions to weigh, never parsed
// into report.Recommendation. Only Claude's own final response — via
// SplitReport — ever drives portfolio.Apply's trade execution.
const AdvisorPreamble = `You are a financial analyst. Given the following market prices and recent news for a fixed watchlist (crypto, NASDAQ equities, futures, forex), give a concise independent investment analysis: what stands out, and any assets you'd flag as a buy or sell with a one-sentence reason each. Plain text, under 400 words.`
// AdvisorResult is one advisor CLI's outcome. Err is set (and Output empty)
// on failure — callers should log it and simply omit this advisor from the
// final prompt, not fail the run.
type AdvisorResult struct {
Name string
Output string
Err error
}
// RunAdvisors runs every configured advisor CLI concurrently, piping
// AdvisorPreamble + dataPrompt to each one's stdin, and returns one result
// per advisor in the same order as cfg.
func RunAdvisors(ctx context.Context, advisors []config.AdvisorConfig, dataPrompt string) []AdvisorResult {
prompt := AdvisorPreamble + "\n\n" + dataPrompt
results := make([]AdvisorResult, len(advisors))
var wg sync.WaitGroup
for i, adv := range advisors {
wg.Add(1)
go func(i int, adv config.AdvisorConfig) {
defer wg.Done()
out, err := runAdvisorCLI(ctx, adv, prompt)
results[i] = AdvisorResult{Name: adv.Name, Output: out, Err: err}
}(i, adv)
}
wg.Wait()
return results
}
func runAdvisorCLI(ctx context.Context, adv config.AdvisorConfig, prompt string) (string, error) {
if _, err := exec.LookPath(adv.Command); err != nil {
return "", fmt.Errorf("command %q not found on PATH: %w", adv.Command, err)
}
cmd := exec.CommandContext(ctx, adv.Command, adv.Args...)
cmd.Stdin = strings.NewReader(prompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%s failed: %w (stderr: %s)", adv.Command, err, lastLine(stderr.String()))
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", fmt.Errorf("%s produced no output (stderr: %s)", adv.Command, lastLine(stderr.String()))
}
return out, nil
}
// lastLine returns the last non-empty line of s, for a compact one-line
// error summary out of what can be many lines of CLI log noise.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}