Implement comprehensive A/B testing system for landing page optimization: - Database models for experiments, variants, and events - AI-powered variant generation and analysis - Visitor tracking with conversion metrics - Experiment lifecycle management (draft/active/paused/completed) - Email notifications for experiment results - Cron job for automated experiment monitoring - UI for viewing experiment results and statistics
194 lines
5.6 KiB
Go
194 lines
5.6 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package pages
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
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"
|
|
pages_module "code.gitcaddy.com/server/v3/modules/pages"
|
|
"code.gitcaddy.com/server/v3/modules/setting"
|
|
)
|
|
|
|
// experimentTokenSecret is derived from the app's secret key
|
|
func experimentTokenSecret() []byte {
|
|
h := sha256.Sum256([]byte(setting.SecretKey + ":page-experiment"))
|
|
return h[:]
|
|
}
|
|
|
|
// CreateExperimentToken creates an HMAC-signed token for experiment approval.
|
|
func CreateExperimentToken(experimentID int64, action string) string {
|
|
data := fmt.Sprintf("%d:%s:%d", experimentID, action, time.Now().Unix())
|
|
mac := hmac.New(sha256.New, experimentTokenSecret())
|
|
mac.Write([]byte(data))
|
|
sig := hex.EncodeToString(mac.Sum(nil))
|
|
return hex.EncodeToString([]byte(data)) + "." + sig
|
|
}
|
|
|
|
// VerifyExperimentToken verifies an experiment approval token and returns the experiment ID.
|
|
func VerifyExperimentToken(_ context.Context, tokenStr string) (string, error) {
|
|
parts := strings.SplitN(tokenStr, ".", 2)
|
|
if len(parts) != 2 {
|
|
return "", errors.New("invalid token format")
|
|
}
|
|
|
|
dataBytes, err := hex.DecodeString(parts[0])
|
|
if err != nil {
|
|
return "", errors.New("invalid token encoding")
|
|
}
|
|
data := string(dataBytes)
|
|
|
|
// Verify HMAC
|
|
mac := hmac.New(sha256.New, experimentTokenSecret())
|
|
mac.Write(dataBytes)
|
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
|
if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) {
|
|
return "", errors.New("invalid token signature")
|
|
}
|
|
|
|
// Parse data: "experimentID:action:timestamp"
|
|
dataParts := strings.SplitN(data, ":", 3)
|
|
if len(dataParts) != 3 {
|
|
return "", errors.New("invalid token data")
|
|
}
|
|
|
|
// Check expiry (7 days)
|
|
ts, err := strconv.ParseInt(dataParts[2], 10, 64)
|
|
if err != nil {
|
|
return "", errors.New("invalid token timestamp")
|
|
}
|
|
if time.Since(time.Unix(ts, 0)) > 7*24*time.Hour {
|
|
return "", errors.New("token expired")
|
|
}
|
|
|
|
return dataParts[0], nil
|
|
}
|
|
|
|
// GenerateExperiment uses the AI sidecar to create an A/B test experiment for a landing page.
|
|
func GenerateExperiment(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) (*pages_model.PageExperiment, error) {
|
|
if !ai.IsEnabled() {
|
|
return nil, errors.New("AI service is not enabled")
|
|
}
|
|
|
|
client := ai.GetClient()
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
|
|
RepoID: repo.ID,
|
|
Task: "ab_test_generate",
|
|
Context: map[string]string{
|
|
"landing_config": string(configJSON),
|
|
"repo_name": repo.Name,
|
|
"repo_description": repo.Description,
|
|
"instruction": `Analyze this landing page config and create an A/B test experiment.
|
|
Return valid JSON with this exact structure:
|
|
{
|
|
"name": "Short experiment name",
|
|
"variants": [
|
|
{
|
|
"name": "Variant A",
|
|
"config_override": { ... partial landing config fields to override ... },
|
|
"weight": 50
|
|
}
|
|
]
|
|
}
|
|
Focus on high-impact changes: headlines, CTAs, value propositions.
|
|
Keep variants meaningfully different but plausible.
|
|
The control variant (original config) is added automatically — do NOT include it.
|
|
Return 1-3 variants. Each variant's config_override should be a partial
|
|
LandingConfig with only the fields that differ from the control.`,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("AI task failed: %w", err)
|
|
}
|
|
|
|
if !resp.Success {
|
|
return nil, fmt.Errorf("AI task error: %s", resp.Error)
|
|
}
|
|
|
|
// Parse AI response
|
|
var result struct {
|
|
Name string `json:"name"`
|
|
Variants []struct {
|
|
Name string `json:"name"`
|
|
ConfigOverride map[string]any `json:"config_override"`
|
|
Weight int `json:"weight"`
|
|
} `json:"variants"`
|
|
}
|
|
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse AI response: %w", err)
|
|
}
|
|
|
|
if result.Name == "" || len(result.Variants) == 0 {
|
|
return nil, errors.New("AI returned empty experiment")
|
|
}
|
|
|
|
// Create experiment
|
|
exp := &pages_model.PageExperiment{
|
|
RepoID: repo.ID,
|
|
Name: result.Name,
|
|
Status: pages_model.ExperimentStatusDraft,
|
|
CreatedByAI: true,
|
|
}
|
|
if err := pages_model.CreateExperiment(ctx, exp); err != nil {
|
|
return nil, fmt.Errorf("failed to create experiment: %w", err)
|
|
}
|
|
|
|
// Create control variant
|
|
controlVariant := &pages_model.PageVariant{
|
|
ExperimentID: exp.ID,
|
|
Name: "Control",
|
|
IsControl: true,
|
|
Weight: 50,
|
|
}
|
|
if err := pages_model.CreateVariant(ctx, controlVariant); err != nil {
|
|
return nil, fmt.Errorf("failed to create control variant: %w", err)
|
|
}
|
|
|
|
// Create AI-generated variants
|
|
remainingWeight := 50
|
|
for i, v := range result.Variants {
|
|
overrideJSON, err := json.Marshal(v.ConfigOverride)
|
|
if err != nil {
|
|
log.Error("Failed to marshal variant %d config: %v", i, err)
|
|
continue
|
|
}
|
|
|
|
weight := v.Weight
|
|
if weight <= 0 {
|
|
weight = remainingWeight / (len(result.Variants) - i)
|
|
}
|
|
remainingWeight -= weight
|
|
|
|
variant := &pages_model.PageVariant{
|
|
ExperimentID: exp.ID,
|
|
Name: v.Name,
|
|
IsControl: false,
|
|
Weight: weight,
|
|
ConfigOverride: string(overrideJSON),
|
|
}
|
|
if err := pages_model.CreateVariant(ctx, variant); err != nil {
|
|
log.Error("Failed to create variant %s: %v", v.Name, err)
|
|
}
|
|
}
|
|
|
|
return exp, nil
|
|
}
|