Files
2026-05-31 16:49:06 -04:00

109 lines
2.9 KiB
Go

// 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
AllowedWindowClasses []string
}
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"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
}
// 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
}
classes := make([]string, 0, len(raw.AllowedWindowClasses))
for _, cls := range raw.AllowedWindowClasses {
if t := strings.TrimSpace(cls); t != "" {
classes = append(classes, t)
}
}
return Proposal{
NextAction: na,
SuccessCondition: sc,
TimeboxSecs: raw.TimeboxMinutes * 60,
AllowedWindowClasses: classes,
}, nil
}