Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m54s
Build and Release / Unit Tests (push) Successful in 6m17s
Build and Release / Lint (push) Successful in 6m26s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m25s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h3m49s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 5m57s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 5m46s
Build and Release / Build Binary (linux/arm64) (push) Failing after 10m31s
Remove inline instruction prompts from experiment generation and analysis. These instructions are now defined in ABTestGeneratePlugin and ABTestAnalyzePlugin, eliminating duplication and improving maintainability.
182 lines
5.4 KiB
Go
182 lines
5.4 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package pages
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
|
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
|
"code.gitcaddy.com/server/v3/modules/ai"
|
|
"code.gitcaddy.com/server/v3/modules/json"
|
|
"code.gitcaddy.com/server/v3/modules/log"
|
|
)
|
|
|
|
// AnalysisResult holds the AI's analysis of an experiment.
|
|
type AnalysisResult struct {
|
|
Status string `json:"status"` // "winner", "needs_more_data", "no_difference"
|
|
WinnerVariantID int64 `json:"winner_variant_id"`
|
|
Confidence float64 `json:"confidence"`
|
|
Summary string `json:"summary"`
|
|
Recommendation string `json:"recommendation"`
|
|
}
|
|
|
|
// AnalyzeExperiment uses the AI sidecar to evaluate experiment results.
|
|
func AnalyzeExperiment(ctx context.Context, exp *pages_model.PageExperiment) (*AnalysisResult, error) {
|
|
if !ai.IsEnabled() {
|
|
return nil, errors.New("AI service is not enabled")
|
|
}
|
|
|
|
// Load variants
|
|
variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load variants: %w", err)
|
|
}
|
|
|
|
// Load event counts by variant
|
|
eventCounts, err := pages_model.GetEventCountsByExperiment(ctx, exp.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load event counts: %w", err)
|
|
}
|
|
|
|
// Build summary data
|
|
type variantSummary struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
IsControl bool `json:"is_control"`
|
|
Impressions int64 `json:"impressions"`
|
|
Conversions int64 `json:"conversions"`
|
|
ConversionRate float64 `json:"conversion_rate"`
|
|
Events map[string]int64 `json:"events"`
|
|
}
|
|
|
|
summaries := make([]variantSummary, 0, len(variants))
|
|
for _, v := range variants {
|
|
vs := variantSummary{
|
|
ID: v.ID,
|
|
Name: v.Name,
|
|
IsControl: v.IsControl,
|
|
Impressions: v.Impressions,
|
|
Conversions: v.Conversions,
|
|
ConversionRate: v.ConversionRate(),
|
|
Events: eventCounts[v.ID],
|
|
}
|
|
summaries = append(summaries, vs)
|
|
}
|
|
|
|
variantsJSON, _ := json.Marshal(summaries)
|
|
experimentJSON, _ := json.Marshal(map[string]any{
|
|
"id": exp.ID,
|
|
"name": exp.Name,
|
|
"created_at": exp.CreatedUnix,
|
|
})
|
|
|
|
client := ai.GetClient()
|
|
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
|
|
RepoID: exp.RepoID,
|
|
Task: "ab_test_analyze",
|
|
Context: map[string]string{
|
|
"experiment": string(experimentJSON),
|
|
"variants": string(variantsJSON),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("AI analysis failed: %w", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
return nil, fmt.Errorf("AI analysis error: %s", resp.Error)
|
|
}
|
|
|
|
var result AnalysisResult
|
|
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse AI analysis: %w", err)
|
|
}
|
|
|
|
// If winner found, update experiment status
|
|
if result.Status == "winner" {
|
|
if err := pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusCompleted); err != nil {
|
|
log.Error("Failed to update experiment status: %v", err)
|
|
}
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// AnalyzeAllActiveExperiments analyzes all active experiments that have enough data.
|
|
func AnalyzeAllActiveExperiments(ctx context.Context) error {
|
|
experiments, err := pages_model.GetAllActiveExperiments(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load active experiments: %w", err)
|
|
}
|
|
|
|
for _, exp := range experiments {
|
|
// Load variants to check if we have enough data
|
|
variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
|
|
if err != nil {
|
|
log.Error("Failed to load variants for experiment %d: %v", exp.ID, err)
|
|
continue
|
|
}
|
|
|
|
// Determine minimum impressions threshold
|
|
repo, err := repo_model.GetRepositoryByID(ctx, exp.RepoID)
|
|
if err != nil {
|
|
log.Error("Failed to load repo for experiment %d: %v", exp.ID, err)
|
|
continue
|
|
}
|
|
|
|
config, err := GetPagesConfig(ctx, repo)
|
|
if err != nil {
|
|
log.Error("Failed to load config for experiment %d: %v", exp.ID, err)
|
|
continue
|
|
}
|
|
|
|
minImpressions := int64(config.Experiments.MinImpressions)
|
|
if minImpressions <= 0 {
|
|
minImpressions = 100
|
|
}
|
|
|
|
// Check if all variants have enough impressions
|
|
hasEnoughData := true
|
|
for _, v := range variants {
|
|
if v.Impressions < minImpressions {
|
|
hasEnoughData = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasEnoughData {
|
|
continue
|
|
}
|
|
|
|
result, err := AnalyzeExperiment(ctx, exp)
|
|
if err != nil {
|
|
log.Error("Failed to analyze experiment %d: %v", exp.ID, err)
|
|
continue
|
|
}
|
|
|
|
if result.Status == "winner" && result.WinnerVariantID > 0 {
|
|
log.Info("Experiment %d (%s): winner found (variant %d), confidence %.2f",
|
|
exp.ID, exp.Name, result.WinnerVariantID, result.Confidence)
|
|
|
|
// Send approval email if required
|
|
if config.Experiments.ApprovalRequired {
|
|
sendExperimentApprovalEmail(repo, exp, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendExperimentApprovalEmail logs the experiment completion.
|
|
// Actual email sending is handled by services/mailer/mail_pages.go
|
|
// which is called from the cron task handler to avoid import cycles.
|
|
func sendExperimentApprovalEmail(repo *repo_model.Repository, exp *pages_model.PageExperiment, result *AnalysisResult) {
|
|
log.Info("Experiment %d completed: %s. Winner variant: %d. Approval email should be sent to repo owner %s.",
|
|
exp.ID, result.Summary, result.WinnerVariantID, repo.OwnerName)
|
|
}
|