M2: ai Proposal type, JSON extraction and validation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user