2
0
Files
logikonline cb2791709e
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
refactor(pages): remove inline prompts from A/B test AI calls
Remove inline instruction prompts from experiment generation and analysis. These instructions are now defined in ABTestGeneratePlugin and ABTestAnalyzePlugin, eliminating duplication and improving maintainability.
2026-03-07 16:12:14 -05:00

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)
}