diff --git a/internal/ai/coach.go b/internal/ai/coach.go new file mode 100644 index 0000000..94d1f25 --- /dev/null +++ b/internal/ai/coach.go @@ -0,0 +1,44 @@ +package ai + +import "context" + +// Coach turns a free-text intent into a validated Proposal. +type Coach interface { + Coach(ctx context.Context, intent string) (Proposal, error) +} + +// Backend is one way to reach an LLM CLI. Adapters differ only in the command +// and arguments they run; each returns the model's text answer. +type Backend interface { + Run(ctx context.Context, prompt string) (string, error) + Name() string +} + +// Service implements Coach over any Backend. +type Service struct { + backend Backend +} + +func NewService(b Backend) *Service { return &Service{backend: b} } + +func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) { + out, err := s.backend.Run(ctx, buildPrompt(intent)) + if err != nil { + return Proposal{}, err + } + return parseProposal(out) +} + +func buildPrompt(intent string) string { + return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment. + +Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: +{"next_action": "", "success_condition": "", "timebox_minutes": } + +Rules: +- next_action: a single concrete imperative action, doable now. +- success_condition: observable and verifiable; how you'd know it is done. +- timebox_minutes: a realistic integer number of minutes for the action. + +User intent: ` + intent +} diff --git a/internal/ai/coach_test.go b/internal/ai/coach_test.go new file mode 100644 index 0000000..23b1252 --- /dev/null +++ b/internal/ai/coach_test.go @@ -0,0 +1,49 @@ +package ai + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeBackend struct { + out string + err error + gotPrompt string +} + +func (f *fakeBackend) Run(ctx context.Context, prompt string) (string, error) { + f.gotPrompt = prompt + return f.out, f.err +} +func (f *fakeBackend) Name() string { return "fake" } + +func TestServiceCoachSuccess(t *testing.T) { + fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`} + svc := NewService(fb) + p, err := svc.Coach(context.Background(), "write the tests") + if err != nil { + t.Fatalf("coach: %v", err) + } + if p.NextAction != "Write tests" || p.TimeboxSecs != 1800 { + t.Fatalf("bad proposal: %+v", p) + } + if !strings.Contains(fb.gotPrompt, "write the tests") { + t.Fatalf("prompt should embed the intent, got: %s", fb.gotPrompt) + } +} + +func TestServiceCoachBackendError(t *testing.T) { + fb := &fakeBackend{err: errors.New("boom")} + if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil { + t.Fatal("want backend error") + } +} + +func TestServiceCoachUnparseable(t *testing.T) { + fb := &fakeBackend{out: "I cannot help with that."} + if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) { + t.Fatalf("want ErrNoJSON, got %v", err) + } +}