feat: dedup and format the output
This commit is contained in:
8 files changed
+153
-54
No files matched your search
+26
-11
@@ -12,22 +12,29 @@ import (
|
||||
|
||||
// SystemPrompt instructs Claude how to analyze the fetched data and format
|
||||
// its response for posting to Discord.
|
||||
const SystemPrompt = `You are a financial analyst producing a concise daily briefing for a personal Discord channel.
|
||||
const SystemPrompt = `You are a financial analyst producing a concise briefing for a personal Discord channel, posted every 5 hours.
|
||||
|
||||
You will be given today's prices (with percent change) and recent news headlines for a fixed watchlist of cryptocurrencies, NASDAQ equities, futures (metals, crude oil), and forex pairs.
|
||||
|
||||
Structure your response in Markdown, formatted for Discord (short lines, ` + "`**bold**`" + ` section headers, no HTML, no huge tables):
|
||||
Structure your response in Markdown, formatted for Discord (short lines, ` + "`**bold**`" + ` section headers, no HTML, no huge tables). Use exactly these section headers, in exactly this order, every single time — do not rename, reorder, merge, or skip a section (write "nothing notable" under a section rather than omitting it):
|
||||
|
||||
1. A one-paragraph market-wide summary.
|
||||
2. A "Crypto" section: brief commentary per asset grounded in the given price/news data.
|
||||
3. A "NASDAQ Equities" section: same, grouped sensibly (e.g. by sector) if useful.
|
||||
4. A "Futures" section (gold, silver, crude oil).
|
||||
5. A "Forex" section covering the given currency pairs.
|
||||
6. A short "Watch" section flagging anything time-sensitive (earnings, macro events implied by the news) for the next day or two.
|
||||
1. **Summary** — one paragraph, market-wide.
|
||||
2. **Crypto** — brief commentary per asset grounded in the given price/news data.
|
||||
3. **NASDAQ Equities** — same, grouped sensibly (e.g. by sector) if useful.
|
||||
4. **Futures** — gold, silver, crude oil.
|
||||
5. **Forex** — the given currency pairs.
|
||||
6. **Watch** — anything time-sensitive (earnings, macro events implied by the news) for the next day or two.
|
||||
|
||||
Keep the whole message under roughly 1500 words. Ground every claim in the provided data — do not fabricate news or prices. If data for an asset is missing, say so briefly rather than inventing a view. End with a one-line disclaimer that this is not financial advice.
|
||||
|
||||
If the user turn includes a section titled "Other AI Analysts' Independent Takes", those are additional perspectives from other AI models given the same data — not ground truth and not your own reasoning. Weigh them alongside the price/news data, note explicitly where you agree or disagree and why, but reach your own independent conclusion. Do not simply restate their conclusions.
|
||||
## Deduplicating against the previous briefing
|
||||
|
||||
If the user turn includes a section titled "Previous Briefing", that was posted about 5 hours ago to the same channel — treat it as what the reader already knows, not as data to re-derive conclusions from. Since reports repeat several times a day:
|
||||
- Do not re-explain news items, price moves, or commentary that already appeared there and have not materially changed since. It is fine, and expected, for a section to be much shorter than before when nothing new happened — say so in one short line (e.g. "Crypto: no material change since last briefing.") rather than restating the same analysis in new words.
|
||||
- Always still report today's current price and percent change for every asset — that number itself is never a repeat, only the commentary around it can be.
|
||||
- Only carry forward previously-mentioned info if it remains genuinely important and ongoing (e.g. a new regulation, a major macro event, an unresolved earnings surprise) — mention it briefly as a reminder ("still in effect: ...") rather than re-explaining it in full.
|
||||
- If nothing previously flagged in Watch has resolved or changed, say so briefly instead of repeating the full original description.
|
||||
- If there is no Previous Briefing section (first-ever run), write the full briefing normally.
|
||||
|
||||
After the full briefing above, on its own line, output exactly:
|
||||
===RECOMMENDATIONS_JSON===
|
||||
@@ -36,12 +43,20 @@ followed immediately by a single JSON object (no markdown code fence, no comment
|
||||
"action" must be exactly "buy" or "sell". Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral. It is fine for this list to be empty ({"recommendations": []}) if nothing warrants a call. This JSON must be the very last thing in your response.`
|
||||
|
||||
// BuildUserPrompt renders the fetched result into the user-turn text sent
|
||||
// alongside SystemPrompt.
|
||||
func BuildUserPrompt(targets config.Targets, result fetch.Result) string {
|
||||
// alongside SystemPrompt. previousReport is the narrative text of the last
|
||||
// archived briefing (see archive.LoadLatest), used so Claude can dedup this
|
||||
// briefing against it; pass "" if none exists yet (e.g. first-ever run).
|
||||
func BuildUserPrompt(targets config.Targets, result fetch.Result, previousReport string) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02"))
|
||||
|
||||
if previousReport != "" {
|
||||
b.WriteString("## Previous Briefing (posted ~5 hours ago — see system prompt for how to use this)\n\n")
|
||||
b.WriteString(previousReport)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("## Prices\n\n")
|
||||
writeQuoteSection(&b, "Crypto", targets.Crypto, result.Quotes)
|
||||
writeQuoteSection(&b, "NASDAQ Equities", targets.Equities, result.Quotes)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package archive optionally saves a local copy of each run's fetched data
|
||||
// and analysis, for debugging prompt/parsing issues without reproducing a
|
||||
// live run.
|
||||
// Package archive saves a local copy of each run's fetched data and
|
||||
// analysis. It doubles as the source of the previous run's report, which
|
||||
// the prompt uses to dedup successive briefings against each other.
|
||||
package archive
|
||||
|
||||
import (
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,13 +32,9 @@ type Run struct {
|
||||
// path. Callers should treat a non-nil error as non-fatal — archiving is a
|
||||
// debugging aid, not part of the report pipeline.
|
||||
func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
|
||||
base := cfg.Dir
|
||||
if base == "" {
|
||||
base = defaultDir
|
||||
}
|
||||
base, err := expandHome(base)
|
||||
base, err := resolveDir(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, now.UTC().Format("20060102T150405Z"))
|
||||
@@ -70,6 +67,59 @@ func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// LoadLatest reads back the most recent previously-archived run's
|
||||
// report.md, for the prompt to dedup the next briefing against. Returns
|
||||
// ("", nil) if no prior run exists yet (e.g. first-ever run) — callers
|
||||
// should treat that as "no previous report" rather than an error.
|
||||
func LoadLatest(cfg config.ArchiveConfig) (string, error) {
|
||||
base, err := resolveDir(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("archive: list %q: %w", base, err)
|
||||
}
|
||||
|
||||
var runDirs []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
runDirs = append(runDirs, e.Name())
|
||||
}
|
||||
}
|
||||
if len(runDirs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
sort.Strings(runDirs) // timestamp-named dirs sort chronologically
|
||||
latest := runDirs[len(runDirs)-1]
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(base, latest, "report.md"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("archive: read previous report: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// resolveDir returns cfg.Dir (or defaultDir if empty) with "~" expanded.
|
||||
func resolveDir(cfg config.ArchiveConfig) (string, error) {
|
||||
base := cfg.Dir
|
||||
if base == "" {
|
||||
base = defaultDir
|
||||
}
|
||||
base, err := expandHome(base)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: %w", err)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// expandHome replaces a leading "~" with the current user's home directory.
|
||||
func expandHome(path string) (string, error) {
|
||||
if path != "~" && !strings.HasPrefix(path, "~/") {
|
||||
|
||||
@@ -76,13 +76,13 @@ type AdvisorConfig struct {
|
||||
}
|
||||
|
||||
// ArchiveConfig controls saving a local copy of each run's fetched data and
|
||||
// analysis — useful for debugging prompt/parsing issues without needing to
|
||||
// reproduce a live run.
|
||||
// analysis. Archiving is always on: besides being a debugging aid, the
|
||||
// previous run's report.md is read back on every run to let the prompt
|
||||
// dedup against it (see analyze.BuildUserPrompt).
|
||||
type ArchiveConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Dir is the directory each run's files are written under, one
|
||||
// timestamped subdirectory per run. Supports a leading "~". Defaults to
|
||||
// "~/.local/state/stonks/runs" if enabled with Dir left empty.
|
||||
// "~/.local/state/stonks/runs" if left empty.
|
||||
Dir string `yaml:"dir"`
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user