M2: ai Coach interface and Service over a pluggable Backend
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user