164 lines
4.7 KiB
Go
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
|
|
}
|