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 }