Thread optional grounding into the coach prompt
The ai.Coach interface gains a grounding parameter carrying standing context about the user (the knowledge port). buildPrompt injects an "About the user" block only when grounding is non-empty, so an empty grounding produces a byte-for-byte unchanged prompt. Drift judge and nudge are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+13
-8
@@ -2,9 +2,10 @@ package ai
|
||||
|
||||
import "context"
|
||||
|
||||
// Coach turns a free-text intent into a validated Proposal.
|
||||
// Coach turns a free-text intent into a validated Proposal. grounding is
|
||||
// optional standing context about the user (the knowledge port); "" means none.
|
||||
type Coach interface {
|
||||
Coach(ctx context.Context, intent string) (Proposal, error)
|
||||
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
|
||||
}
|
||||
|
||||
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
||||
@@ -21,16 +22,16 @@ type Service struct {
|
||||
|
||||
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))
|
||||
func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) {
|
||||
out, err := s.backend.Run(ctx, buildPrompt(intent, grounding))
|
||||
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.
|
||||
func buildPrompt(intent, grounding string) string {
|
||||
preamble := `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>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
|
||||
@@ -39,9 +40,13 @@ 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.
|
||||
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.
|
||||
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.`
|
||||
|
||||
User intent: ` + intent
|
||||
about := ""
|
||||
if grounding != "" {
|
||||
about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them."
|
||||
}
|
||||
return preamble + about + "\n\nUser intent: " + intent
|
||||
}
|
||||
|
||||
// DriftJudge decides whether the current window is on-task for a commitment.
|
||||
|
||||
@@ -22,7 +22,7 @@ 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")
|
||||
p, err := svc.Coach(context.Background(), "write the tests", "")
|
||||
if err != nil {
|
||||
t.Fatalf("coach: %v", err)
|
||||
}
|
||||
@@ -36,14 +36,34 @@ func TestServiceCoachSuccess(t *testing.T) {
|
||||
|
||||
func TestServiceCoachBackendError(t *testing.T) {
|
||||
fb := &fakeBackend{err: errors.New("boom")}
|
||||
if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil {
|
||||
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) {
|
||||
if _, err := NewService(fb).Coach(context.Background(), "x", ""); !errors.Is(err, ErrNoJSON) {
|
||||
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoachPromptIncludesGrounding(t *testing.T) {
|
||||
fb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
|
||||
if _, err := NewService(fb).Coach(context.Background(), "ship it", "I value small diffs."); err != nil {
|
||||
t.Fatalf("coach: %v", err)
|
||||
}
|
||||
if !strings.Contains(fb.gotPrompt, "About the user") || !strings.Contains(fb.gotPrompt, "small diffs") {
|
||||
t.Fatalf("prompt missing grounding block: %s", fb.gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoachEmptyGroundingUnchanged(t *testing.T) {
|
||||
withFb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
|
||||
_, _ = NewService(withFb).Coach(context.Background(), "ship it", "")
|
||||
// The empty-grounding prompt must equal buildPrompt(intent) of the old shape:
|
||||
// it must NOT contain the grounding header.
|
||||
if strings.Contains(withFb.gotPrompt, "About the user") {
|
||||
t.Fatalf("empty grounding leaked a header: %s", withFb.gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user