fix: deliver validated AI insight responses
This commit is contained in:
277
internal/coach/service.go
Normal file
277
internal/coach/service.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type repositoryStore interface {
|
||||
Profile(context.Context, int64, string) (Profile, error)
|
||||
SaveProfile(context.Context, int64, int64, string, string, string, ProfileInput) (Profile, bool, error)
|
||||
terminal(context.Context, int64, string) (int64, error)
|
||||
Enqueue(context.Context, enqueueInput) (Job, bool, error)
|
||||
EnqueueManual(context.Context, enqueueInput) (Job, bool, error)
|
||||
Claim(context.Context) (claimedJob, error)
|
||||
Evidence(context.Context, claimedJob) (Profile, MetricEvidence, error)
|
||||
Complete(context.Context, claimedJob, Generation, MetricEvidence, ProviderUsage, string) error
|
||||
Fail(context.Context, claimedJob, string) error
|
||||
Dashboard(context.Context, int64, string) (Dashboard, error)
|
||||
Plans(context.Context, int64, string) ([]Plan, error)
|
||||
Actions(context.Context, int64, string) ([]Action, error)
|
||||
Goals(context.Context, int64, string) ([]MonthlyGoal, error)
|
||||
ProposeEventDeadlines(context.Context, int64, string) (int64, error)
|
||||
TransitionSubtask(context.Context, int64, int64, string, string, string, string, string) (GoalSubtask, error)
|
||||
DecideDeadlineProposal(context.Context, int64, int64, string, string, string, string) (DeadlineProposal, error)
|
||||
DecideRecommendation(context.Context, int64, int64, string, string, string, DecisionInput) (*Action, error)
|
||||
Transition(context.Context, int64, int64, string, string, string, string) (Action, error)
|
||||
GetJob(context.Context, int64, string) (Job, error)
|
||||
CurrentProfiles(context.Context) ([]scheduledProfile, error)
|
||||
EvaluateMetrics(context.Context, string) (int, error)
|
||||
CurrentWatermark(context.Context) (string, error)
|
||||
DecideDifficulty(context.Context, int64, int64, string, string, string, string) (DifficultyProposal, error)
|
||||
EvaluateDifficulty(context.Context) (int, error)
|
||||
EventsAfter(context.Context, int64, string, int64) ([]Event, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository repositoryStore
|
||||
generator Generator
|
||||
now func() time.Time
|
||||
dailyRequests int64
|
||||
dailyTokens int64
|
||||
}
|
||||
|
||||
func (s *Service) WithBudget(requests, tokens int64) *Service {
|
||||
s.dailyRequests = requests
|
||||
s.dailyTokens = tokens
|
||||
return s
|
||||
}
|
||||
|
||||
func NewService(repository repositoryStore, generator Generator) *Service {
|
||||
return &Service{repository: repository, generator: generator, now: time.Now}
|
||||
}
|
||||
func (s *Service) Profile(ctx context.Context, merchantID int64, terminalID string) (Profile, error) {
|
||||
if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return s.repository.Profile(ctx, merchantID, terminalID)
|
||||
}
|
||||
func (s *Service) SaveProfile(ctx context.Context, merchantID, userID int64, terminalID, key string, input ProfileInput) (Profile, []Job, bool, error) {
|
||||
input = NormalizeProfileInput(input)
|
||||
if err := input.Validate(); err != nil {
|
||||
return Profile{}, nil, false, err
|
||||
}
|
||||
hash := hashJSON(input)
|
||||
profile, cached, err := s.repository.SaveProfile(ctx, merchantID, userID, terminalID, key, hash, input)
|
||||
if err != nil {
|
||||
return Profile{}, nil, false, err
|
||||
}
|
||||
if cached {
|
||||
return profile, nil, true, nil
|
||||
}
|
||||
jobs := []Job{}
|
||||
for _, cadence := range []string{"MONTHLY", "WEEKLY", "DAILY"} {
|
||||
job, _, err := s.enqueue(ctx, merchantID, terminalID, profile, cadence, "PROFILE_CREATED", "")
|
||||
if err != nil {
|
||||
return Profile{}, nil, false, err
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
if _, err := s.repository.ProposeEventDeadlines(ctx, merchantID, terminalID); err != nil {
|
||||
return Profile{}, nil, false, err
|
||||
}
|
||||
return profile, jobs, false, nil
|
||||
}
|
||||
func (s *Service) Dashboard(ctx context.Context, merchantID int64, terminalID string) (Dashboard, error) {
|
||||
return s.repository.Dashboard(ctx, merchantID, terminalID)
|
||||
}
|
||||
|
||||
func (s *Service) Plans(ctx context.Context, merchantID int64, terminalID string) ([]Plan, error) {
|
||||
if terminalID != "" {
|
||||
if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.repository.Plans(ctx, merchantID, terminalID)
|
||||
}
|
||||
func (s *Service) Actions(ctx context.Context, merchantID int64, terminalID string) ([]Action, error) {
|
||||
if terminalID != "" {
|
||||
if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.repository.Actions(ctx, merchantID, terminalID)
|
||||
}
|
||||
func (s *Service) Goals(ctx context.Context, merchantID int64, terminalID string) ([]MonthlyGoal, error) {
|
||||
if terminalID != "" {
|
||||
if _, err := s.repository.terminal(ctx, merchantID, terminalID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.repository.Goals(ctx, merchantID, terminalID)
|
||||
}
|
||||
|
||||
func (s *Service) TransitionSubtask(ctx context.Context, merchantID, userID int64, goalID, subtaskID, key string, input SubtaskTransitionInput) (GoalSubtask, error) {
|
||||
if input.Transition != "COMPLETE" && input.Transition != "REOPEN" {
|
||||
return GoalSubtask{}, ErrInvalidTransition
|
||||
}
|
||||
return s.repository.TransitionSubtask(ctx, merchantID, userID, goalID, subtaskID, key, hashJSON(map[string]any{"goalId": goalID, "subtaskId": subtaskID, "transition": input.Transition}), input.Transition)
|
||||
}
|
||||
|
||||
func (s *Service) DecideDeadlineProposal(ctx context.Context, merchantID, userID int64, proposalID, key string, input DeadlineDecisionInput) (DeadlineProposal, error) {
|
||||
if input.Decision != "ACCEPT" && input.Decision != "REJECT" {
|
||||
return DeadlineProposal{}, ErrInvalidTransition
|
||||
}
|
||||
return s.repository.DecideDeadlineProposal(ctx, merchantID, userID, proposalID, key, hashJSON(map[string]any{"proposalId": proposalID, "decision": input.Decision}), input.Decision)
|
||||
}
|
||||
func (s *Service) Refresh(ctx context.Context, merchantID int64, terminalID, key string, input RefreshInput) (Job, bool, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
profile, err := s.Profile(ctx, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
terminalDBID, err := s.repository.terminal(ctx, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
start, end := PeriodBounds(input.Cadence, s.now())
|
||||
fingerprint := hashString(fmt.Sprintf("%d|%s|%s|%s|%d|MANUAL|%s", merchantID, terminalID, input.Cadence, start.Format(time.DateOnly), profile.Version, key))
|
||||
job, cached, err := s.repository.EnqueueManual(ctx, enqueueInput{MerchantID: merchantID, TerminalID: terminalDBID, TerminalKey: terminalID, ProfileID: profile.ProfileID, Cadence: input.Cadence, Reason: "MANUAL", Fingerprint: fingerprint, IdempotencyKey: key, RequestHash: hashString(terminalID + "|" + input.Cadence), Start: start, End: end})
|
||||
if errors.Is(err, ErrRefreshLimited) {
|
||||
return Job{}, false, &RefreshLimitError{NextRefreshAt: end.AddDate(0, 0, 1)}
|
||||
}
|
||||
return job, cached, err
|
||||
}
|
||||
func (s *Service) enqueue(ctx context.Context, merchantID int64, terminalID string, profile Profile, cadence, reason, key string) (Job, bool, error) {
|
||||
terminalDBID, err := s.repository.terminal(ctx, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
start, end := PeriodBounds(cadence, s.now())
|
||||
fingerprint := hashString(fmt.Sprintf("%d|%s|%s|%s|%d", merchantID, terminalID, cadence, start.Format(time.DateOnly), profile.Version))
|
||||
return s.repository.Enqueue(ctx, enqueueInput{MerchantID: merchantID, TerminalID: terminalDBID, TerminalKey: terminalID, ProfileID: profile.ProfileID, Cadence: cadence, Reason: reason, Fingerprint: fingerprint, IdempotencyKey: key, RequestHash: hashString(terminalID + cadence), Start: start, End: end})
|
||||
}
|
||||
func (s *Service) ProcessNext(ctx context.Context) error {
|
||||
job, err := s.repository.Claim(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.generator == nil {
|
||||
_ = s.repository.Fail(ctx, job, "PROVIDER_UNAVAILABLE")
|
||||
return nil
|
||||
}
|
||||
if usageRepository, ok := s.repository.(interface {
|
||||
DailyProviderUsage(context.Context) (int64, int64, error)
|
||||
}); ok && (s.dailyRequests > 0 || s.dailyTokens > 0) {
|
||||
requests, tokens, usageErr := usageRepository.DailyProviderUsage(ctx)
|
||||
if usageErr != nil {
|
||||
_ = s.repository.Fail(ctx, job, "BUDGET_CHECK_FAILED")
|
||||
return usageErr
|
||||
}
|
||||
if (s.dailyRequests > 0 && requests >= s.dailyRequests) || (s.dailyTokens > 0 && tokens >= s.dailyTokens) {
|
||||
_ = s.repository.Fail(ctx, job, "BUDGET_EXHAUSTED")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
profile, evidence, err := s.repository.Evidence(ctx, job)
|
||||
if err != nil {
|
||||
_ = s.repository.Fail(ctx, job, "EVIDENCE_FAILED")
|
||||
return err
|
||||
}
|
||||
generation, usage, err := s.generator.Generate(ctx, job.Cadence, profile, evidence)
|
||||
if err != nil {
|
||||
_ = s.repository.Fail(ctx, job, "PROVIDER_OR_VALIDATION_FAILED")
|
||||
return err
|
||||
}
|
||||
return s.repository.Complete(ctx, job, generation, evidence, usage, s.generator.Model())
|
||||
}
|
||||
func (s *Service) ScheduleDue(ctx context.Context) (int, error) {
|
||||
profiles, err := s.repository.CurrentProfiles(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
created := 0
|
||||
now := s.now()
|
||||
dailyStart, _ := PeriodBounds("DAILY", now)
|
||||
weekStart, _ := PeriodBounds("WEEKLY", now)
|
||||
monthStart, _ := PeriodBounds("MONTHLY", now)
|
||||
for _, item := range profiles {
|
||||
cadences := []string{"DAILY"}
|
||||
if dailyStart.Equal(weekStart) {
|
||||
cadences = append(cadences, "WEEKLY")
|
||||
}
|
||||
if dailyStart.Equal(monthStart) {
|
||||
cadences = append(cadences, "MONTHLY")
|
||||
}
|
||||
for _, cadence := range cadences {
|
||||
start, end := PeriodBounds(cadence, now)
|
||||
fingerprint := hashString(fmt.Sprintf("%d|%s|%s|%s|%d", item.MerchantID, item.Profile.TerminalID, cadence, start.Format(time.DateOnly), item.Profile.Version))
|
||||
_, cached, err := s.repository.Enqueue(ctx, enqueueInput{MerchantID: item.MerchantID, TerminalID: item.TerminalDBID, TerminalKey: item.Profile.TerminalID, ProfileID: item.Profile.ProfileID, Cadence: cadence, Reason: "SCHEDULED", Fingerprint: fingerprint, Start: start, End: end})
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
if !cached {
|
||||
created++
|
||||
}
|
||||
}
|
||||
if _, err := s.repository.ProposeEventDeadlines(ctx, item.MerchantID, item.Profile.TerminalID); err != nil {
|
||||
return created, err
|
||||
}
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) DecideDifficulty(ctx context.Context, merchantID, userID int64, id, key string, input DecisionInput) (DifficultyProposal, error) {
|
||||
return s.repository.DecideDifficulty(ctx, merchantID, userID, id, key, hashJSON(map[string]any{"id": id, "decision": input.Decision}), input.Decision)
|
||||
}
|
||||
func (s *Service) DecideRecommendation(ctx context.Context, merchantID, userID int64, id, key string, input DecisionInput) (*Action, error) {
|
||||
if input.Decision != "ACCEPTED" && input.Decision != "REJECTED" {
|
||||
return nil, ErrInvalidTransition
|
||||
}
|
||||
if len([]rune(input.Note)) > 500 {
|
||||
return nil, errors.New("growth coach: decision note too long")
|
||||
}
|
||||
return s.repository.DecideRecommendation(ctx, merchantID, userID, id, key, hashJSON(map[string]any{"id": id, "input": input}), input)
|
||||
}
|
||||
func (s *Service) Transition(ctx context.Context, merchantID, userID int64, id, key string, input TransitionInput) (Action, error) {
|
||||
return s.repository.Transition(ctx, merchantID, userID, id, key, hashJSON(map[string]any{"id": id, "transition": input.Transition}), input.Transition)
|
||||
}
|
||||
func (s *Service) GetJob(ctx context.Context, merchantID int64, id string) (Job, error) {
|
||||
return s.repository.GetJob(ctx, merchantID, id)
|
||||
}
|
||||
func (s *Service) EventsAfter(ctx context.Context, merchantID int64, id string, after int64) ([]Event, error) {
|
||||
return s.repository.EventsAfter(ctx, merchantID, id, after)
|
||||
}
|
||||
func (s *Service) EvaluateMetrics(ctx context.Context) (int, error) {
|
||||
watermark, err := s.repository.CurrentWatermark(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.repository.EvaluateMetrics(ctx, watermark)
|
||||
}
|
||||
func (s *Service) EvaluateDifficulty(ctx context.Context) (int, error) {
|
||||
return s.repository.EvaluateDifficulty(ctx)
|
||||
}
|
||||
|
||||
// PaymentUpdateListener is the stable boundary used by the CSV importer today
|
||||
// and by a future webhook/API adapter without changing coach evaluation rules.
|
||||
type PaymentUpdateListener interface {
|
||||
OnPaymentUpdate(context.Context, string) error
|
||||
}
|
||||
|
||||
func (s *Service) OnPaymentUpdate(ctx context.Context, watermark string) error {
|
||||
_, err := s.repository.EvaluateMetrics(ctx, watermark)
|
||||
return err
|
||||
}
|
||||
func hashJSON(value any) string { data, _ := json.Marshal(value); return hashString(string(data)) }
|
||||
func hashString(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in New Issue
Block a user