Add reviewer role to the ai port
A fourth leaf role: Review(finished, history) returns a two-line Reflection (recap + carry_forward) over the same CLI backend as the coach. Primitive-only, with a JSON prompt and a tolerant parser that rejects a blank recap but allows an empty carry_forward. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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": "<one sentence: how this session went>", "carry_forward": "<one sentence: the single most useful thing to do differently next session>"}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user