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
|
||||
}
|
||||
Reference in New Issue
Block a user