Files
stonks/internal/archive/archive.go
T

137 lines
3.8 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)
}
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,
}
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
}
// 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, "~/") {
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
}