Files
felixm e4e34b092c 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>
2026-06-01 09:24:04 -04:00

85 lines
3.0 KiB
Go

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
}