Files
stonks/internal/archive/archive.go
T
2026-09-11 09:54:49 +08:00

164 lines
4.7 KiB
Go

// 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 (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.konchin.com/service/stonks/internal/config"
"gitea.konchin.com/service/stonks/internal/fetch"
"gitea.konchin.com/service/stonks/internal/report"
)
const defaultDir = "~/.local/state/stonks/runs"
// Run holds everything one pipeline run produced, for archiving.
type Run struct {
Result fetch.Result
Prompt string // final prompt sent to the analyzer, including any advisor section
RawResponse string // analyzer's raw, unparsed response
Report report.Report
}
// Save writes Run's artifacts to a new timestamped subdirectory under
// cfg.Dir (or defaultDir if cfg.Dir is empty) and returns that directory's
// 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, err := resolveDir(cfg)
if err != nil {
return "", err
}
dir := filepath.Join(base, now.UTC().Format("20060102T150405Z"))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("archive: create %q: %w", dir, err)
}
fetchedJSON, err := json.MarshalIndent(run.Result, "", " ")
if err != nil {
return "", fmt.Errorf("archive: marshal fetched data: %w", err)
}
recsJSON, err := json.MarshalIndent(run.Report.Recommendations, "", " ")
if err != nil {
return "", fmt.Errorf("archive: marshal recommendations: %w", err)
}
portfolioJSON, err := json.MarshalIndent(run.Report.Portfolio, "", " ")
if err != nil {
return "", fmt.Errorf("archive: marshal portfolio snapshot: %w", err)
}
files := map[string][]byte{
"fetched.json": fetchedJSON,
"prompt.txt": []byte(run.Prompt),
"analysis_raw.md": []byte(run.RawResponse),
"report.md": []byte(run.Report.Text),
"recommendations.json": recsJSON,
"portfolio.json": portfolioJSON,
"memory.md": []byte(run.Report.Memory),
}
for name, data := range files {
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
return dir, fmt.Errorf("archive: write %q: %w", name, err)
}
}
return dir, nil
}
// RecentRun is one previously-archived run's timestamp and report text, as
// returned by LoadRecent.
type RecentRun struct {
When time.Time
Report string
}
// runDirLayout is the timestamp format run directories are named with (see
// Save), used by LoadRecent to recover each run's time.
const runDirLayout = "20060102T150405Z"
// LoadRecent reads back up to the last n previously-archived runs' report.md
// contents, oldest first, for the prompt to dedup the next briefing against
// a rolling window of recent history. Returns (nil, nil) if no prior run
// exists yet (e.g. first-ever run) — callers should treat that as "no
// previous reports" rather than an error. Runs whose report.md is missing or
// unreadable are skipped rather than failing the whole call.
func LoadRecent(cfg config.ArchiveConfig, n int) ([]RecentRun, error) {
base, err := resolveDir(cfg)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(base)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, 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, nil
}
sort.Strings(runDirs) // timestamp-named dirs sort chronologically
if len(runDirs) > n {
runDirs = runDirs[len(runDirs)-n:]
}
var runs []RecentRun
for _, name := range runDirs {
when, err := time.Parse(runDirLayout, name)
if err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(base, name, "report.md"))
if err != nil {
continue
}
runs = append(runs, RecentRun{When: when.UTC(), Report: string(data)})
}
return runs, 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, "~/") {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
if path == "~" {
return home, nil
}
return filepath.Join(home, path[2:]), nil
}