Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/mark2note/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,18 +674,21 @@ var nowFunc = time.Now
var buildDeckJSON = func(cfg *config.Config, markdown string) (string, error) {
b := ai.Builder{}
b.SetCommand(cfg.AI.Command, cfg.AI.Args)
b.SetRetryDelays(cfg.AI.Retry.Delays)
return b.BuildDeckJSON(markdown)
}

var buildPublishTopics = func(cfg *config.Config, markdown string, title string) ([]string, error) {
b := ai.TopicBuilder{}
b.SetCommand(cfg.AI.Command, cfg.AI.Args)
b.SetRetryDelays(cfg.AI.Retry.Delays)
return b.BuildPublishTopics(markdown, title)
}

var buildPublishTitle = func(cfg *config.Config, markdown string, title string, maxRunes int) (string, error) {
b := ai.TitleBuilder{}
b.SetCommand(cfg.AI.Command, cfg.AI.Args)
b.SetRetryDelays(cfg.AI.Retry.Delays)
return b.BuildPublishTitle(markdown, title, maxRunes)
}

Expand Down
7 changes: 7 additions & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ ai:
args:
- codex
- --bare
retry:
delays:
- 1s
- 2s
- 5s
- 9s
- 17s

deck:
# deck.theme_mode 可选值:fixed / weekly;weekly 会按本机星期选择 weekly_themes 中的固定主题。
Expand Down
29 changes: 23 additions & 6 deletions internal/ai/deck_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type Builder struct {
Args []string
PromptExtra string
MaxPages int
RetryDelays []time.Duration
Runner CommandRunner
}

Expand Down Expand Up @@ -103,21 +104,33 @@ func (execRunner) Run(name string, args ...string) (string, string, error) {
return "", "", err
}

var aiCommandRetryDelays = []time.Duration{time.Second, 3 * time.Second}
var defaultAICommandRetryDelays = []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 9 * time.Second, 17 * time.Second}

func runAICommand(runner CommandRunner, name string, args ...string) (string, string, error) {
func cloneDurations(values []time.Duration) []time.Duration {
return append([]time.Duration(nil), values...)
}

func effectiveAICommandRetryDelays(delays []time.Duration) []time.Duration {
if delays == nil {
return cloneDurations(defaultAICommandRetryDelays)
}
return cloneDurations(delays)
}

func runAICommand(runner CommandRunner, name string, retryDelays []time.Duration, args ...string) (string, string, error) {
var stdout string
var stderr string
var err error
for attempt := 0; attempt <= len(aiCommandRetryDelays); attempt++ {
delays := effectiveAICommandRetryDelays(retryDelays)
for attempt := 0; attempt <= len(delays); attempt++ {
stdout, stderr, err = runner.Run(name, args...)
if err == nil {
return stdout, stderr, nil
}
if attempt == len(aiCommandRetryDelays) || !isTransientAICommandError(err, stdout, stderr) {
if attempt == len(delays) || !isTransientAICommandError(err, stdout, stderr) {
return stdout, stderr, err
}
time.Sleep(aiCommandRetryDelays[attempt])
time.Sleep(delays[attempt])
}
return stdout, stderr, err
}
Expand Down Expand Up @@ -151,6 +164,10 @@ func (b *Builder) SetCommand(command string, args []string) {
b.Args = append([]string(nil), args...)
}

func (b *Builder) SetRetryDelays(delays []time.Duration) {
b.RetryDelays = cloneDurations(delays)
}

func (b Builder) effectiveRunner() CommandRunner {
if b.Runner != nil {
return b.Runner
Expand All @@ -164,7 +181,7 @@ func (b Builder) BuildDeckJSON(markdown string) (string, error) {
args = append(args, "--bare")
}
args = append(args, "-p", buildDeckPromptWithMaxPages(markdown, b.PromptExtra, b.MaxPages))
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...)
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, b.RetryDelays, args...)
if err != nil {
return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
}
Expand Down
23 changes: 23 additions & 0 deletions internal/ai/deck_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"reflect"
"strings"
"testing"
"time"
)

type fakeRunner struct {
Expand Down Expand Up @@ -371,6 +372,7 @@ func TestBuildPublishTopicsRetriesTransientAILockError(t *testing.T) {
}}
b := TopicBuilder{Runner: runner}
b.SetCommand("ccs", []string{"codex"})
b.SetRetryDelays([]time.Duration{0})

got, err := b.BuildPublishTopics("# 标题", "标题")
if err != nil {
Expand Down Expand Up @@ -429,6 +431,7 @@ func TestBuildPublishTitleRetriesTransientAILockError(t *testing.T) {
}}
b := TitleBuilder{Runner: runner}
b.SetCommand("ccs", []string{"codex"})
b.SetRetryDelays([]time.Duration{0})

got, err := b.BuildPublishTitle("# 标题", "标题", 20)
if err != nil {
Expand Down Expand Up @@ -503,6 +506,7 @@ func TestBuildDeckJSONRetriesTransientAILockError(t *testing.T) {
}}
b := Builder{Runner: runner}
b.SetCommand("ccs", []string{"codex"})
b.SetRetryDelays([]time.Duration{0})

got, err := b.BuildDeckJSON("# title")
if err != nil {
Expand All @@ -516,6 +520,25 @@ func TestBuildDeckJSONRetriesTransientAILockError(t *testing.T) {
}
}

func TestBuildDeckJSONUsesConfiguredRetryDelays(t *testing.T) {
runner := &sequenceRunner{calls: []runnerCall{
{stderr: "timeout", err: errors.New("exit status 1")},
{stderr: "timeout", err: errors.New("exit status 1")},
{stdout: `{"pages":[]}`},
}}
b := Builder{Runner: runner}
b.SetCommand("custom-ai", nil)
b.SetRetryDelays([]time.Duration{0, 0})

_, err := b.BuildDeckJSON("# title")
if err != nil {
t.Fatalf("BuildDeckJSON() error = %v", err)
}
if runner.count != 3 {
t.Fatalf("runner calls = %d, want len(delays)+1", runner.count)
}
}

func TestBuildDeckJSONAppendsBareForCCSCodex(t *testing.T) {
runner := &fakeRunner{stdout: `{"pages":[]}`}
b := Builder{Runner: runner}
Expand Down
14 changes: 10 additions & 4 deletions internal/ai/title_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
)

const publishTitlePromptTemplate = `你是一个小红书发布标题改写器。
Expand All @@ -24,9 +25,10 @@ Markdown 如下:
%s`

type TitleBuilder struct {
Command string
Args []string
Runner CommandRunner
Command string
Args []string
RetryDelays []time.Duration
Runner CommandRunner
}

type titleResponse struct {
Expand All @@ -42,6 +44,10 @@ func (b *TitleBuilder) SetCommand(command string, args []string) {
b.Args = append([]string(nil), args...)
}

func (b *TitleBuilder) SetRetryDelays(delays []time.Duration) {
b.RetryDelays = cloneDurations(delays)
}

func (b TitleBuilder) effectiveRunner() CommandRunner {
if b.Runner != nil {
return b.Runner
Expand All @@ -55,7 +61,7 @@ func (b TitleBuilder) BuildPublishTitle(markdown, title string, maxRunes int) (s
args = append(args, "--bare")
}
args = append(args, "-p", buildPublishTitlePrompt(markdown, title, maxRunes))
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...)
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, b.RetryDelays, args...)
if err != nil {
return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
}
Expand Down
14 changes: 10 additions & 4 deletions internal/ai/topic_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
)

const topicPromptTemplate = `你是一个小红书发布话题生成器。
Expand All @@ -24,9 +25,10 @@ Markdown 如下:
%s`

type TopicBuilder struct {
Command string
Args []string
Runner CommandRunner
Command string
Args []string
RetryDelays []time.Duration
Runner CommandRunner
}

type topicResponse struct {
Expand All @@ -42,6 +44,10 @@ func (b *TopicBuilder) SetCommand(command string, args []string) {
b.Args = append([]string(nil), args...)
}

func (b *TopicBuilder) SetRetryDelays(delays []time.Duration) {
b.RetryDelays = cloneDurations(delays)
}

func (b TopicBuilder) effectiveRunner() CommandRunner {
if b.Runner != nil {
return b.Runner
Expand All @@ -55,7 +61,7 @@ func (b TopicBuilder) BuildPublishTopics(markdown, title string) ([]string, erro
args = append(args, "--bare")
}
args = append(args, "-p", buildTopicPrompt(markdown, title))
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...)
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, b.RetryDelays, args...)
if err != nil {
return nil, fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
}
Expand Down
1 change: 1 addition & 0 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ func (s Service) effectiveBuildDeckJSON() func(*config.Config, string) (string,
return func(cfg *config.Config, markdown string) (string, error) {
b := ai.Builder{PromptExtra: s.PromptExtra, MaxPages: cfg.Deck.MaxPages, Runner: s.AICommandRunner}
b.SetCommand(cfg.AI.Command, cfg.AI.Args)
b.SetRetryDelays(cfg.AI.Retry.Delays)
return b.BuildDeckJSON(markdown)
}
}
Expand Down
32 changes: 30 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@ type OutputCfg struct {
}

type AICfg struct {
Command string `yaml:"command"`
Args []string `yaml:"args"`
Command string `yaml:"command"`
Args []string `yaml:"args"`
Retry AIRetryCfg `yaml:"retry"`
}

type AIRetryCfg struct {
Delays []time.Duration `yaml:"delays"`
}

const (
Expand Down Expand Up @@ -188,6 +193,21 @@ func parseConfigDuration(value string) (time.Duration, error) {
return parsed, nil
}

var defaultAIRetryDelays = []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 9 * time.Second, 17 * time.Second}

func cloneDurations(values []time.Duration) []time.Duration {
return append([]time.Duration(nil), values...)
}

func validateAIRetryDelays(delays []time.Duration) error {
for i, delay := range delays {
if delay <= 0 {
return fmt.Errorf("validate ai.retry.delays[%d]: must be > 0", i)
}
}
return nil
}

var defaultDeckWeeklyThemes = map[string]string{
"mon": "default",
"tue": "warm-paper",
Expand Down Expand Up @@ -279,6 +299,14 @@ func Load(configPath string) (*Config, error) {
if len(cfg.AI.Args) == 0 {
cfg.AI.Args = []string{"codex", "--bare"}
}
if cfg.AI.Retry.Delays == nil {
cfg.AI.Retry.Delays = cloneDurations(defaultAIRetryDelays)
} else {
cfg.AI.Retry.Delays = cloneDurations(cfg.AI.Retry.Delays)
}
if err := validateAIRetryDelays(cfg.AI.Retry.Delays); err != nil {
return nil, err
}
if cfg.Deck.Theme == "" {
cfg.Deck.Theme = "default"
}
Expand Down
36 changes: 36 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ func TestLoadAppliesDefaultAIConfig(t *testing.T) {
if !reflect.DeepEqual(cfg.AI.Args, []string{"codex", "--bare"}) {
t.Fatalf("AI.Args = %v, want %v", cfg.AI.Args, []string{"codex", "--bare"})
}
wantRetryDelays := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 9 * time.Second, 17 * time.Second}
if !reflect.DeepEqual(cfg.AI.Retry.Delays, wantRetryDelays) {
t.Fatalf("AI.Retry.Delays = %v, want %v", cfg.AI.Retry.Delays, wantRetryDelays)
}
}

func TestLoadParsesAIRetryDelays(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := "ai:\n retry:\n delays:\n - 2s\n - 11s\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
want := []time.Duration{2 * time.Second, 11 * time.Second}
if !reflect.DeepEqual(cfg.AI.Retry.Delays, want) {
t.Fatalf("AI.Retry.Delays = %v, want %v", cfg.AI.Retry.Delays, want)
}
}

func TestLoadRejectsInvalidAIRetryDelay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := "ai:\n retry:\n delays:\n - 0s\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

_, err := Load(path)
if err == nil || !strings.Contains(err.Error(), "ai.retry.delays[0]") {
t.Fatalf("Load() error = %v, want ai.retry.delays[0] validation error", err)
}
}

func TestLoadAppliesDefaultDeckConfig(t *testing.T) {
Expand Down