fix: deliver validated AI insight responses
This commit is contained in:
338
internal/coach/model.go
Normal file
338
internal/coach/model.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("growth coach: not found")
|
||||
ErrUnknownTerminal = errors.New("growth coach: unknown terminal")
|
||||
ErrIdempotencyConflict = errors.New("growth coach: idempotency conflict")
|
||||
ErrInvalidTransition = errors.New("growth coach: invalid transition")
|
||||
ErrInvalidCadence = errors.New("growth coach: invalid refresh cadence")
|
||||
ErrRefreshLimited = errors.New("growth coach: refresh limited")
|
||||
)
|
||||
|
||||
type RefreshLimitError struct{ NextRefreshAt time.Time }
|
||||
|
||||
func (e *RefreshLimitError) Error() string {
|
||||
return "growth coach: refresh limited until " + e.NextRefreshAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (e *RefreshLimitError) Unwrap() error { return ErrRefreshLimited }
|
||||
|
||||
type RefreshInput struct {
|
||||
Cadence string `json:"cadence"`
|
||||
}
|
||||
|
||||
func (r RefreshInput) Validate() error {
|
||||
if !ManualRefreshAllowed(r.Cadence, 0) {
|
||||
return ErrInvalidCadence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProfileInput struct {
|
||||
BusinessDescription string `json:"businessDescription"`
|
||||
Niches []string `json:"niches"`
|
||||
SalesChannels []string `json:"salesChannels"`
|
||||
PrimaryGoal string `json:"primaryGoal"`
|
||||
Constraints string `json:"constraints"`
|
||||
}
|
||||
|
||||
func (p ProfileInput) Validate() error {
|
||||
descriptionLength := len([]rune(strings.TrimSpace(p.BusinessDescription)))
|
||||
goalLength := len([]rune(strings.TrimSpace(p.PrimaryGoal)))
|
||||
if descriptionLength < 20 || descriptionLength > 2000 || goalLength < 3 || goalLength > 500 || len([]rune(p.Constraints)) > 1000 {
|
||||
return errors.New("growth coach: invalid profile text")
|
||||
}
|
||||
if len(p.Niches) < 1 || len(p.Niches) > 10 || len(p.SalesChannels) < 1 || len(p.SalesChannels) > 10 {
|
||||
return errors.New("growth coach: invalid profile selections")
|
||||
}
|
||||
for _, value := range append(append([]string{}, p.Niches...), p.SalesChannels...) {
|
||||
if length := len([]rune(strings.TrimSpace(value))); length < 1 || length > 80 {
|
||||
return errors.New("growth coach: invalid profile selection")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
Version int `json:"version"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
ProfileInput
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ChangedFields []string `json:"changedFields"`
|
||||
PreviousInput *ProfileInput `json:"-"`
|
||||
}
|
||||
|
||||
func NormalizeProfileInput(input ProfileInput) ProfileInput {
|
||||
input.BusinessDescription = strings.Join(strings.Fields(input.BusinessDescription), " ")
|
||||
input.PrimaryGoal = strings.Join(strings.Fields(input.PrimaryGoal), " ")
|
||||
input.Constraints = strings.Join(strings.Fields(input.Constraints), " ")
|
||||
input.Niches = normalizeProfileList(input.Niches)
|
||||
input.SalesChannels = normalizeProfileList(input.SalesChannels)
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeProfileList(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func ChangedProfileFields(previous, next ProfileInput) []string {
|
||||
previous, next = NormalizeProfileInput(previous), NormalizeProfileInput(next)
|
||||
fields := []string{}
|
||||
if previous.BusinessDescription != next.BusinessDescription {
|
||||
fields = append(fields, "businessDescription")
|
||||
}
|
||||
if !slices.Equal(previous.Niches, next.Niches) {
|
||||
fields = append(fields, "niches")
|
||||
}
|
||||
if !slices.Equal(previous.SalesChannels, next.SalesChannels) {
|
||||
fields = append(fields, "salesChannels")
|
||||
}
|
||||
if previous.PrimaryGoal != next.PrimaryGoal {
|
||||
fields = append(fields, "primaryGoal")
|
||||
}
|
||||
if previous.Constraints != next.Constraints {
|
||||
fields = append(fields, "constraints")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
type Recommendation struct {
|
||||
RecommendationID string `json:"recommendationId"`
|
||||
PlanID string `json:"planId"`
|
||||
Rank int `json:"rank"`
|
||||
Status string `json:"status"`
|
||||
ActionType string `json:"actionType"`
|
||||
Title string `json:"title"`
|
||||
Rationale string `json:"rationale"`
|
||||
ExpectedImpact string `json:"expectedImpact"`
|
||||
Effort string `json:"effort"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
MetricID *string `json:"metricId,omitempty"`
|
||||
MetricDirection *string `json:"metricDirection,omitempty"`
|
||||
ActionLevel *string `json:"actionLevel,omitempty"`
|
||||
Baseline *float64 `json:"baseline,omitempty"`
|
||||
Target *float64 `json:"target,omitempty"`
|
||||
SampleSize int64 `json:"sampleSize"`
|
||||
Deadline string `json:"deadline"`
|
||||
Subtasks []GenerationSubtask `json:"subtasks"`
|
||||
EventIDs []string `json:"eventIds"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
PlanID string `json:"planId"`
|
||||
ParentID *string `json:"parentId,omitempty"`
|
||||
Cadence string `json:"cadence"`
|
||||
Status string `json:"status"`
|
||||
PeriodStart string `json:"periodStart"`
|
||||
PeriodEnd string `json:"periodEnd"`
|
||||
DailyBrief *string `json:"dailyBrief,omitempty"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
Suggestions []Recommendation `json:"suggestions"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
ActionID string `json:"actionId"`
|
||||
RecommendationID string `json:"recommendationId"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
ActionType string `json:"actionType"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
MetricID *string `json:"metricId,omitempty"`
|
||||
MetricDirection *string `json:"metricDirection,omitempty"`
|
||||
Baseline *float64 `json:"baseline,omitempty"`
|
||||
Target *float64 `json:"target,omitempty"`
|
||||
CurrentValue *float64 `json:"currentValue,omitempty"`
|
||||
SampleSize int64 `json:"sampleSize"`
|
||||
Deadline string `json:"deadline"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type TerminalRollup struct {
|
||||
TerminalID string `json:"terminalId"`
|
||||
OnboardingStatus string `json:"onboardingStatus"`
|
||||
ActiveActions int `json:"activeActions"`
|
||||
OverdueActions int `json:"overdueActions"`
|
||||
CompletionRate float64 `json:"completionRate"`
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Scope string `json:"scope"`
|
||||
TerminalID *string `json:"terminalId,omitempty"`
|
||||
DataThrough *string `json:"dataThrough,omitempty"`
|
||||
GenerationState string `json:"generationState"`
|
||||
Profile *Profile `json:"profile,omitempty"`
|
||||
Plans []Plan `json:"plans"`
|
||||
Actions []Action `json:"actions"`
|
||||
Terminals []TerminalRollup `json:"terminals"`
|
||||
DifficultyProposals []DifficultyProposal `json:"difficultyProposals"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
JobID string `json:"jobId"`
|
||||
State string `json:"state"`
|
||||
EventsURL string `json:"eventsUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"eventId"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
type DecisionInput struct {
|
||||
Decision string `json:"decision"`
|
||||
Reason string `json:"reason"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type TransitionInput struct {
|
||||
Transition string `json:"transition"`
|
||||
}
|
||||
|
||||
type DifficultyProposal struct {
|
||||
ProposalID string `json:"proposalId"`
|
||||
FromLevel int `json:"fromLevel"`
|
||||
ToLevel int `json:"toLevel"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type GenerationSuggestion struct {
|
||||
ActionType string `json:"actionType"`
|
||||
Title string `json:"title"`
|
||||
Rationale string `json:"rationale"`
|
||||
ExpectedImpact string `json:"expectedImpact"`
|
||||
Effort string `json:"effort"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
MetricID *string `json:"metricId"`
|
||||
MetricDirection *string `json:"metricDirection"`
|
||||
ActionLevel *string `json:"actionLevel"`
|
||||
Subtasks []GenerationSubtask `json:"subtasks"`
|
||||
EventIDs []string `json:"eventIds"`
|
||||
}
|
||||
|
||||
type GenerationSubtask struct {
|
||||
Title string `json:"title"`
|
||||
Deadline *string `json:"deadline"`
|
||||
EventID *string `json:"eventId"`
|
||||
}
|
||||
|
||||
type GoalSubtask struct {
|
||||
SubtaskID string `json:"subtaskId"`
|
||||
Position int `json:"position"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Deadline string `json:"deadline"`
|
||||
EventID *string `json:"eventId,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type MonthlyGoal struct {
|
||||
GoalID string `json:"goalId"`
|
||||
RecommendationID string `json:"recommendationId"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
Title string `json:"title"`
|
||||
ExpectedImpact string `json:"expectedImpact"`
|
||||
Status string `json:"status"`
|
||||
MetricID string `json:"metricId"`
|
||||
MetricDirection string `json:"metricDirection"`
|
||||
Baseline float64 `json:"baseline"`
|
||||
Target float64 `json:"target"`
|
||||
CurrentValue float64 `json:"currentValue"`
|
||||
ProgressPercent float64 `json:"progressPercent"`
|
||||
SampleSize int64 `json:"sampleSize"`
|
||||
Deadline string `json:"deadline"`
|
||||
EventIDs []string `json:"eventIds"`
|
||||
Subtasks []GoalSubtask `json:"subtasks"`
|
||||
DeadlineProposals []DeadlineProposal `json:"deadlineProposals"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SubtaskTransitionInput struct {
|
||||
Transition string `json:"transition"`
|
||||
}
|
||||
|
||||
type DeadlineProposal struct {
|
||||
ProposalID string `json:"proposalId"`
|
||||
GoalID string `json:"goalId"`
|
||||
SubtaskID string `json:"subtaskId"`
|
||||
EventID string `json:"eventId"`
|
||||
CurrentDeadline string `json:"currentDeadline"`
|
||||
ProposedDeadline string `json:"proposedDeadline"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `json:"status"`
|
||||
DecidedAt *time.Time `json:"decidedAt,omitempty"`
|
||||
}
|
||||
|
||||
type DeadlineDecisionInput struct {
|
||||
Decision string `json:"decision"`
|
||||
}
|
||||
|
||||
type Generation struct {
|
||||
DailyBrief string `json:"dailyBrief"`
|
||||
Suggestions []GenerationSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
type ProviderUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
ProviderRequests int64
|
||||
LatencyMS int64
|
||||
ProviderRequestID string
|
||||
EstimatedCost string
|
||||
}
|
||||
|
||||
type MetricEvidence struct {
|
||||
Sessions int64 `json:"sessions"`
|
||||
Values map[string]float64 `json:"values"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
DataThrough string `json:"dataThrough"`
|
||||
RecentDecisions []CoachMemoryItem `json:"recentDecisions"`
|
||||
RecentOutcomes []CoachMemoryItem `json:"recentOutcomes"`
|
||||
HasAcceptedMonthlyGoal bool `json:"hasAcceptedMonthlyGoal"`
|
||||
Industry string `json:"industry"`
|
||||
RelevantEvents []CoachEvent `json:"relevantEvents"`
|
||||
}
|
||||
|
||||
type CoachEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type CoachMemoryItem struct {
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user