diff --git a/internal/ai/reflection.go b/internal/ai/reflection.go new file mode 100644 index 0000000..4e5239d --- /dev/null +++ b/internal/ai/reflection.go @@ -0,0 +1,84 @@ +package ai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Reflection is the reviewer's output: two short, single-line fields. +type Reflection struct { + Recap string // backward-looking: how the session went (shown on Review) + CarryForward string // forward-looking: one takeaway for the next session +} + +// Reviewer reflects on a just-finished session, read against recent history. It +// takes primitives, not domain/store types, so ai stays a leaf package. +// +// finished: a compact description of the session that just ended. +// history: a compact description of the last few prior sessions ("" if none). +type Reviewer interface { + Review(ctx context.Context, finished, history string) (Reflection, error) +} + +// ErrInvalidReflection marks output that parsed as JSON but lacked a usable +// recap, so callers can distinguish "no attempt" from "bad attempt". +var ErrInvalidReflection = errors.New("ai: invalid reflection") + +// Review makes Service satisfy Reviewer over the same backend as Coach. +func (s *Service) Review(ctx context.Context, finished, history string) (Reflection, error) { + out, err := s.backend.Run(ctx, buildReviewPrompt(finished, history)) + if err != nil { + return Reflection{}, err + } + return parseReflection(out) +} + +func buildReviewPrompt(finished, history string) string { + preamble := `You are a focus reviewer. A work session just ended. Reflect on it in two short lines. + +Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: +{"recap": "", "carry_forward": ""} + +Rules: +- recap: one short sentence, backward-looking, grounded in the session below. +- carry_forward: one short, actionable sentence for the next session. It may reference patterns across the recent sessions if any are given. +- Keep each field to a single short sentence.` + + hist := "" + if strings.TrimSpace(history) != "" { + hist = "\n\n## Recent sessions (oldest first)\n" + history + } + return preamble + "\n\n## Session that just ended\n" + finished + hist +} + +type rawReflection struct { + Recap string `json:"recap"` + CarryForward string `json:"carry_forward"` +} + +// parseReflection extracts a Reflection from raw CLI output. A blank recap is +// rejected (ErrInvalidReflection); an empty carry_forward is allowed. +func parseReflection(s string) (Reflection, error) { + if strings.TrimSpace(s) == "" { + return Reflection{}, ErrEmptyResponse + } + if strings.IndexByte(s, '{') < 0 { + return Reflection{}, ErrNoJSON + } + jsonStr, err := extractJSON(s) + if err != nil { + return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err) + } + var raw rawReflection + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err) + } + recap := strings.TrimSpace(raw.Recap) + if recap == "" { + return Reflection{}, ErrInvalidReflection + } + return Reflection{Recap: recap, CarryForward: strings.TrimSpace(raw.CarryForward)}, nil +} diff --git a/internal/ai/reflection_test.go b/internal/ai/reflection_test.go new file mode 100644 index 0000000..ec454b5 --- /dev/null +++ b/internal/ai/reflection_test.go @@ -0,0 +1,77 @@ +package ai + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestReviewPromptIncludesSessionAndHistory(t *testing.T) { + fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`} + if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", "HISTORY-BLOCK"); err != nil { + t.Fatalf("review: %v", err) + } + if !strings.Contains(fb.gotPrompt, "FINISHED-BLOCK") { + t.Fatalf("prompt missing finished session: %s", fb.gotPrompt) + } + if !strings.Contains(fb.gotPrompt, "HISTORY-BLOCK") || !strings.Contains(fb.gotPrompt, "Recent sessions") { + t.Fatalf("prompt missing history block: %s", fb.gotPrompt) + } +} + +func TestReviewPromptOmitsEmptyHistory(t *testing.T) { + fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`} + if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", ""); err != nil { + t.Fatalf("review: %v", err) + } + if strings.Contains(fb.gotPrompt, "Recent sessions") { + t.Fatalf("empty history must not add a history header: %s", fb.gotPrompt) + } +} + +func TestReviewServiceParsesReflection(t *testing.T) { + fb := &fakeBackend{out: `sure: {"recap":"held focus on the port","carry_forward":"start in the editor next time"}`} + refl, err := NewService(fb).Review(context.Background(), "f", "h") + if err != nil { + t.Fatalf("review: %v", err) + } + if refl.Recap != "held focus on the port" || refl.CarryForward != "start in the editor next time" { + t.Fatalf("bad reflection: %+v", refl) + } +} + +func TestReviewServiceBackendError(t *testing.T) { + fb := &fakeBackend{err: errors.New("boom")} + if _, err := NewService(fb).Review(context.Background(), "f", "h"); err == nil { + t.Fatal("want backend error") + } +} + +func TestParseReflectionEmpty(t *testing.T) { + if _, err := parseReflection(""); !errors.Is(err, ErrEmptyResponse) { + t.Fatalf("want ErrEmptyResponse, got %v", err) + } +} + +func TestParseReflectionNoJSON(t *testing.T) { + if _, err := parseReflection("I cannot help."); !errors.Is(err, ErrNoJSON) { + t.Fatalf("want ErrNoJSON, got %v", err) + } +} + +func TestParseReflectionRequiresRecap(t *testing.T) { + if _, err := parseReflection(`{"recap":" ","carry_forward":"x"}`); !errors.Is(err, ErrInvalidReflection) { + t.Fatalf("blank recap should be invalid, got %v", err) + } +} + +func TestParseReflectionAllowsEmptyCarryForward(t *testing.T) { + refl, err := parseReflection(`{"recap":"did the thing","carry_forward":""}`) + if err != nil { + t.Fatalf("carry_forward may be empty: %v", err) + } + if refl.Recap != "did the thing" || refl.CarryForward != "" { + t.Fatalf("bad reflection: %+v", refl) + } +}