Files

339 lines
12 KiB
Go

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"`
}