ai-code-battle/cmd/acb-index-builder/main.go
jedarden 7978ebbab3 feat(§15.2): generate and stream static meta JSON files to R2
- Add data/meta/rivalries.json to R2 upload list in uploadMetaJSONToR2
- Add attachCommunityHints() to narrative.go to enrich story arcs with
  highest-upvote community tactical hints (upvotes >= 3, idea/mistake types)
- Fix detectRivalryArcs() key separator from "-" to "|" to avoid UUID
  hyphen collisions when parsing bot ID pairs
- Fix partitionBots() call sites in bot_strategies_phase13.go to use
  struct field access (.friendly, .enemy) matching updated return type

generator.go already contains generateArchetypes, generateCommunityHints,
and generateMatchFeedback (all called from generateAllIndexes). main.go
uploads all four outputs to R2 on every build cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:46:27 -04:00

343 lines
9.9 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/aicodebattle/acb/metrics"
_ "github.com/lib/pq"
)
func main() {
// Setup structured logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg := LoadConfig()
// Connect to PostgreSQL
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
cfg.PostgresHost, cfg.PostgresPort, cfg.PostgresUser, cfg.PostgresPassword, cfg.PostgresDatabase,
)
db, err := sql.Open("postgres", connStr)
if err != nil {
slog.Error("Failed to connect to PostgreSQL", "error", err)
os.Exit(1)
}
defer db.Close()
// Verify connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := db.PingContext(ctx); err != nil {
cancel()
slog.Error("Failed to ping PostgreSQL", "error", err)
os.Exit(1)
}
cancel()
slog.Info("Connected to PostgreSQL",
"host", cfg.PostgresHost,
"database", cfg.PostgresDatabase,
)
// Start internal metrics server (separate port for Prometheus scraping)
metricsSrv := metrics.StartServer()
defer metricsSrv.Close()
// Create output directory
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
slog.Error("Failed to create output directory", "error", err, "path", cfg.OutputDir)
os.Exit(1)
}
// Initialize crane auth for pulling site build images from the registry
if err := initCraneAuth(cfg); err != nil {
slog.Warn("Failed to initialize crane auth, site builds will use baked-in assets", "error", err)
}
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
startTime := time.Now()
buildCount := 0
for {
// Check lifetime
if time.Since(startTime) > cfg.MaxLifetime {
slog.Info("Max lifetime reached, exiting", "lifetime", cfg.MaxLifetime)
os.Exit(0)
}
// Check for shutdown signal
select {
case sig := <-sigChan:
slog.Info("Received signal, shutting down", "signal", sig)
os.Exit(0)
default:
}
// Run build cycle with timeout
buildCount++
slog.Info("Starting build cycle", "count", buildCount)
buildStart := time.Now()
buildCtx, buildCancel := context.WithTimeout(context.Background(), cfg.BuildTimeout)
if err := runBuildCycle(buildCtx, db, cfg); err != nil {
slog.Error("Build cycle failed", "error", err)
} else {
slog.Info("Build cycle completed", "count", buildCount)
}
buildCancel()
metrics.IndexBuildDuration.Observe(time.Since(buildStart).Seconds())
// Deploy every N cycles
if buildCount%cfg.DeployInterval == 0 {
slog.Info("Deploy interval reached, deploying to Pages")
if err := deployToPages(cfg); err != nil {
slog.Error("Failed to deploy to Pages", "error", err)
} else {
slog.Info("Deployed to Cloudflare Pages")
}
// Run R2 pruning once per week (Monday)
if time.Now().Weekday() == time.Monday {
slog.Info("Running weekly R2 pruning")
if err := pruneR2Cache(context.Background(), cfg); err != nil {
slog.Error("R2 pruning failed", "error", err)
} else {
slog.Info("R2 pruning completed")
}
}
}
// Sleep until next cycle
slog.Info("Sleeping until next build cycle", "duration", cfg.BuildInterval)
time.Sleep(cfg.BuildInterval)
}
}
// uploadMetaJSONToR2 uploads the generated static meta JSON files to R2 so
// they are available at the R2 CDN URL in addition to the Pages deploy.
func uploadMetaJSONToR2(ctx context.Context, cfg *Config, outputDir string, data *IndexData) error {
if cfg.R2AccessKey == "" || cfg.R2BucketName == "" {
return nil
}
static := []string{
"data/meta/archetypes.json",
"data/meta/rivalries.json",
"data/evolution/community_hints.json",
}
for _, rel := range static {
localPath := fmt.Sprintf("%s/%s", outputDir, rel)
if err := uploadFileToR2(ctx, cfg, localPath, rel); err != nil {
slog.Error("Failed to upload meta JSON to R2", "path", rel, "error", err)
}
}
// Upload per-match feedback files for matches that have annotations.
matchIDs := make(map[string]bool)
for _, f := range data.Feedback {
matchIDs[f.MatchID] = true
}
for matchID := range matchIDs {
rel := fmt.Sprintf("data/matches/%s/feedback.json", matchID)
localPath := fmt.Sprintf("%s/%s", outputDir, rel)
if err := uploadFileToR2(ctx, cfg, localPath, rel); err != nil {
slog.Error("Failed to upload match feedback to R2", "match_id", matchID, "error", err)
}
}
slog.Info("Uploaded meta JSON to R2",
"static_files", len(static),
"match_feedback_files", len(matchIDs),
)
return nil
}
// runBuildCycle executes one full index build cycle
func runBuildCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
// Create data directories
dirs := []string{
cfg.OutputDir + "/data",
cfg.OutputDir + "/data/bots",
cfg.OutputDir + "/data/matches",
cfg.OutputDir + "/data/series",
cfg.OutputDir + "/data/seasons",
cfg.OutputDir + "/data/playlists",
cfg.OutputDir + "/data/predictions",
cfg.OutputDir + "/data/meta",
cfg.OutputDir + "/data/evolution",
cfg.OutputDir + "/data/blog",
cfg.OutputDir + "/data/commentary",
cfg.OutputDir + "/cards",
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create dir %s: %w", dir, err)
}
}
// Sync site build from registry (if configured) and copy into output directory.
// Falls back to baked-in assets when registry is unreachable.
webDistDir, siteBuildChanged := syncSiteBuild(ctx, cfg)
// When a new site build is detected, remove old SPA files to prevent
// stale hashed JS/CSS accumulation toward Pages' 20K file limit.
if siteBuildChanged {
slog.Info("Site build changed, cleaning stale web assets before merge")
if err := cleanStaleWebAssets(cfg); err != nil {
slog.Error("Failed to clean stale web assets", "error", err)
}
}
if _, err := os.Stat(webDistDir); err == nil {
if err := copyWebAssets(cfg, webDistDir); err != nil {
slog.Error("Failed to copy web assets", "error", err)
// Non-fatal - continue
}
}
// Fetch all data from PostgreSQL
data, err := fetchAllData(ctx, db)
if err != nil {
return fmt.Errorf("fetch data: %w", err)
}
// Generate all index files
if err := generateAllIndexes(data, cfg.OutputDir, db, cfg); err != nil {
return fmt.Errorf("generate indexes: %w", err)
}
// Upload new static meta JSON files to R2 warm cache
if err := uploadMetaJSONToR2(ctx, cfg, cfg.OutputDir, data); err != nil {
slog.Error("Failed to upload meta JSON to R2", "error", err)
// Non-fatal
}
// Generate blog posts (weekly meta reports and chronicles)
var llmClient *LLMClient
if cfg.LLMBaseURL != "" {
llmClient = NewLLMClient(cfg.LLMBaseURL, cfg.LLMAPIKey)
}
if err := generateBlog(data, cfg.OutputDir, llmClient, cfg); err != nil {
slog.Error("Failed to generate blog", "error", err)
// Non-fatal - continue with rest of build
}
// Generate bot profile cards (PNG images for social sharing)
if err := generateAllBotCards(data, cfg.OutputDir); err != nil {
slog.Error("Failed to generate bot cards", "error", err)
// Non-fatal - continue with rest of build
}
// Upload cards to R2 warm cache
if err := uploadCardsToR2(ctx, cfg, cfg.OutputDir); err != nil {
slog.Error("Failed to upload cards to R2", "error", err)
// Non-fatal
}
// Upload cards to B2 cold archive
if err := uploadCardsToB2(ctx, cfg, cfg.OutputDir); err != nil {
slog.Error("Failed to upload cards to B2", "error", err)
// Non-fatal
}
// Promote recent replays from B2 to R2 warm cache
if err := promoteRecentReplaysForCycle(ctx, db, cfg); err != nil {
slog.Error("Failed to promote recent replays", "error", err)
// Non-fatal
}
// Enrich featured replays with AI commentary (§13.3)
if err := enrichReplays(ctx, data, cfg, llmClient); err != nil {
slog.Error("Failed to enrich replays", "error", err)
// Non-fatal - unenriched replays are still valid
}
// Generate enriched commentary index (list of matches with AI commentary)
if err := generateEnrichedIndex(ctx, data, cfg, cfg.OutputDir); err != nil {
slog.Error("Failed to generate enriched index", "error", err)
// Non-fatal
}
return nil
}
// promoteRecentReplaysForCycle promotes recent replays from B2 to R2
func promoteRecentReplaysForCycle(ctx context.Context, db *sql.DB, cfg *Config) error {
// Get recent match IDs from the last 24 hours
recentMatchIDs, err := fetchRecentMatchIDs(ctx, db, 24*time.Hour)
if err != nil {
return fmt.Errorf("fetch recent match IDs: %w", err)
}
if len(recentMatchIDs) == 0 {
slog.Debug("No recent matches to promote")
return nil
}
// Get exempt match IDs (playlists, series, seasons)
exemptMatchIDs, err := fetchExemptMatchIDs(ctx, db, cfg.OutputDir)
if err != nil {
slog.Warn("Failed to fetch exempt match IDs, promoting all", "error", err)
exemptMatchIDs = make(map[string]bool)
}
// Combine recent and exempt matches for promotion
matchIDsToPromote := recentMatchIDs
for matchID := range exemptMatchIDs {
matchIDsToPromote = append(matchIDsToPromote, matchID)
}
if len(matchIDsToPromote) == 0 {
slog.Debug("No matches to promote")
return nil
}
slog.Info("Promoting replays to R2",
"recent_count", len(recentMatchIDs),
"exempt_count", len(exemptMatchIDs),
"total", len(matchIDsToPromote))
return promoteRecentReplays(ctx, cfg, matchIDsToPromote)
}
// fetchRecentMatchIDs retrieves match IDs from the last duration
func fetchRecentMatchIDs(ctx context.Context, db *sql.DB, since time.Duration) ([]string, error) {
query := `
SELECT match_id
FROM matches
WHERE status = 'completed'
AND completed_at > NOW() - $1::interval
ORDER BY completed_at DESC
`
intervalStr := fmt.Sprintf("%.0f seconds", since.Seconds())
rows, err := db.QueryContext(ctx, query, intervalStr)
if err != nil {
return nil, err
}
defer rows.Close()
var matchIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
matchIDs = append(matchIDs, id)
}
return matchIDs, nil
}