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