339 lines
12 KiB
Go
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"`
|
|
}
|