From 5545a4e1cd234069111849e8847e32c3334a5145 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 14:09:21 -0400 Subject: [PATCH] M2: ai Proposal type, JSON extraction and validation Co-Authored-By: Claude Opus 4.8 --- internal/ai/proposal.go | 95 ++++++++++++++++++++++++++++++++++++ internal/ai/proposal_test.go | 71 +++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 internal/ai/proposal.go create mode 100644 internal/ai/proposal_test.go diff --git a/internal/ai/proposal.go b/internal/ai/proposal.go new file mode 100644 index 0000000..a898d67 --- /dev/null +++ b/internal/ai/proposal.go @@ -0,0 +1,95 @@ +// Package ai is the Advisor port: it turns a user's free-text intent into a +// structured commitment Proposal by shelling out to an LLM CLI. It imports +// nothing from the rest of the app, so it stays a leaf package. +package ai + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Proposal is the coach's structured suggestion for a commitment. It is not a +// domain.Commitment: the AI does not mint IDs, timestamps, or state. +type Proposal struct { + NextAction string + SuccessCondition string + TimeboxSecs int64 +} + +var ( + ErrEmptyResponse = errors.New("ai: empty response") + ErrNoJSON = errors.New("ai: no JSON object in response") + ErrInvalidProposal = errors.New("ai: invalid proposal") +) + +// extractJSON returns the first balanced top-level JSON object in s, tolerating +// leading/trailing prose or code fences around it. +func extractJSON(s string) (string, error) { + start := strings.IndexByte(s, '{') + if start < 0 { + return "", ErrNoJSON + } + depth, inStr, esc := 0, false, false + for i := start; i < len(s); i++ { + ch := s[i] + if inStr { + switch { + case esc: + esc = false + case ch == '\\': + esc = true + case ch == '"': + inStr = false + } + continue + } + switch ch { + case '"': + inStr = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1], nil + } + } + } + return "", ErrNoJSON +} + +type rawProposal struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxMinutes int64 `json:"timebox_minutes"` +} + +// parseProposal extracts and validates a Proposal from raw CLI output. +func parseProposal(s string) (Proposal, error) { + if strings.TrimSpace(s) == "" { + return Proposal{}, ErrEmptyResponse + } + // If there is no opening brace at all, report ErrNoJSON. + // If there is a brace but it is unbalanced or otherwise malformed, + // report ErrInvalidProposal so callers can distinguish "no attempt" + // from "bad attempt". + if strings.IndexByte(s, '{') < 0 { + return Proposal{}, ErrNoJSON + } + jsonStr, err := extractJSON(s) + if err != nil { + return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) + } + var raw rawProposal + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err) + } + na := strings.TrimSpace(raw.NextAction) + sc := strings.TrimSpace(raw.SuccessCondition) + if na == "" || sc == "" || raw.TimeboxMinutes <= 0 { + return Proposal{}, ErrInvalidProposal + } + return Proposal{NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60}, nil +} diff --git a/internal/ai/proposal_test.go b/internal/ai/proposal_test.go new file mode 100644 index 0000000..0fc510d --- /dev/null +++ b/internal/ai/proposal_test.go @@ -0,0 +1,71 @@ +package ai + +import ( + "errors" + "testing" +) + +func TestExtractJSON(t *testing.T) { + cases := []struct { + name, in, want string + wantErr bool + }{ + {"bare", `{"a":1}`, `{"a":1}`, false}, + {"prose around", "sure, here:\n{\"a\":1}\nhope that helps", `{"a":1}`, false}, + {"code fence", "```json\n{\"a\":1}\n```", `{"a":1}`, false}, + {"nested", `{"a":{"b":2},"c":3}`, `{"a":{"b":2},"c":3}`, false}, + {"brace in string", `{"a":"}{"}`, `{"a":"}{"}`, false}, + {"first of two", `{"a":1} then {"b":2}`, `{"a":1}`, false}, + {"none", `no json here`, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := extractJSON(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("want error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +func TestParseProposal(t *testing.T) { + good := `{"next_action":"Draft summary","success_condition":"3 paragraphs written","timebox_minutes":25}` + p, err := parseProposal(good) + if err != nil { + t.Fatalf("good parse: %v", err) + } + if p.NextAction != "Draft summary" || p.SuccessCondition != "3 paragraphs written" { + t.Fatalf("fields wrong: %+v", p) + } + if p.TimeboxSecs != 25*60 { + t.Fatalf("timebox secs got %d want %d", p.TimeboxSecs, 25*60) + } + + if _, err := parseProposal(""); !errors.Is(err, ErrEmptyResponse) { + t.Fatalf("empty: want ErrEmptyResponse, got %v", err) + } + if _, err := parseProposal("no json"); !errors.Is(err, ErrNoJSON) { + t.Fatalf("no json: want ErrNoJSON, got %v", err) + } + bad := []string{ + `{"next_action":"","success_condition":"x","timebox_minutes":25}`, + `{"next_action":"x","success_condition":" ","timebox_minutes":25}`, + `{"next_action":"x","success_condition":"y","timebox_minutes":0}`, + `{"next_action":"x","success_condition":"y","timebox_minutes":-5}`, + `{"next_action":"x"`, + } + for _, in := range bad { + if _, err := parseProposal(in); !errors.Is(err, ErrInvalidProposal) { + t.Fatalf("bad %q: want ErrInvalidProposal, got %v", in, err) + } + } +}