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. 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 "" }