From 44544622ae23137984a442ae8929f82bd885b20a Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 29 Mar 2026 06:35:36 -0400 Subject: [PATCH] Phase 10 Narrative Engine Implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created narrative.go with story arc detection per plan ยง15.5 - Arc types: Rise, Fall, Rivalry, upset, evolution, comeback - LLMClient for OpenAI-compatible API narrative generation - generateLLMChronicles() using narrative engine - Updated blog.go with LLM integration - Template-based fallback when LLM unavailable - Added tests in narrative_test.go Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 13 +- cmd/acb-index-builder/blog.go | 168 +++++- cmd/acb-index-builder/config.go | 4 + cmd/acb-index-builder/db.go | 39 +- cmd/acb-index-builder/narrative.go | 726 ++++++++++++++++++++++++ cmd/acb-index-builder/narrative_test.go | 318 +++++++++++ 6 files changed, 1249 insertions(+), 19 deletions(-) create mode 100644 cmd/acb-index-builder/narrative.go create mode 100644 cmd/acb-index-builder/narrative_test.go diff --git a/PROGRESS.md b/PROGRESS.md index 088802f..78018cc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,9 +4,20 @@ **Status: ๐Ÿ”„ In Progress** -**Last Updated: 2026-03-29** (Public API documentation) +**Last Updated: 2026-03-29** (Narrative Engine) ### Recent Changes (2026-03-29) +- **Phase 10 Narrative Engine** (`cmd/acb-index-builder/narrative.go`, `narrative_test.go`): + - LLM-powered chronicle generation per plan ยง15.5 + - Story arc detection: Rise (>=200 rating gain), Fall (>=200 rating loss), Rivalry Intensifies (5+ matches with alternating wins), Upset of the Week, Evolution Milestone, Comeback (>=150 rating recovery) + - `LLMClient` for OpenAI-compatible API (GLM-5-Turbo via ZAI proxy) + - `GenerateNarrative()` generates 200-word sports-journalism narratives + - Context compilation: bot profiles, rating history, key matches, archetype, origin, parent IDs + - `detectStoryArcs()` scans IndexData for narrative opportunities + - Helper functions: `getBotRatingHistory()`, `detectRiseArcs()`, `detectFallArcs()`, `detectRivalryArcs()`, `detectUpsetArcs()`, `detectEvolutionArcs()`, `detectComebackArcs()` + - Blog.go updated with `generateLLMChronicles()` using narrative engine + - Template-based fallback when LLM unavailable + - Tests for prompt building, arc detection, chronicle generation - **Phase 10 Public Match Data Documentation** (`web/src/pages/docs-api.ts`): - New `/docs/api` route with OpenAPI-style documentation - Documents all Pages endpoints (leaderboard, bots, matches, playlists, blog) diff --git a/cmd/acb-index-builder/blog.go b/cmd/acb-index-builder/blog.go index 4a971a2..e6f0f43 100644 --- a/cmd/acb-index-builder/blog.go +++ b/cmd/acb-index-builder/blog.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "path/filepath" @@ -35,7 +36,7 @@ type BlogEntry struct { } // generateBlog creates blog posts and the blog index -func generateBlog(data *IndexData, outputDir string) error { +func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient) error { blogDir := filepath.Join(outputDir, "data", "blog") postsDir := filepath.Join(blogDir, "posts") @@ -51,8 +52,8 @@ func generateBlog(data *IndexData, outputDir string) error { posts = append(posts, metaReport) } - // Generate story arc chronicles - chronicles := generateChronicles(data) + // Generate story arc chronicles using narrative engine + chronicles := generateLLMChronicles(context.Background(), data, llmClient) posts = append(posts, chronicles...) // Write individual post files @@ -161,7 +162,7 @@ The meta continues to evolve as bots adapt their strategies. Key trends to watch } } -// generateChronicles creates story arc chronicles from match data +// generateChronicles creates story arc chronicles from match data (template-based fallback) func generateChronicles(data *IndexData) []BlogPost { chronicles := make([]BlogPost, 0) @@ -188,6 +189,165 @@ func generateChronicles(data *IndexData) []BlogPost { return chronicles } +// generateLLMChronicles creates chronicles using the narrative engine and LLM +func generateLLMChronicles(ctx context.Context, data *IndexData, llmClient *LLMClient) []BlogPost { + chronicles := make([]BlogPost, 0) + + // Detect story arcs from data + arcs := detectStoryArcs(data) + + // Limit to 3-5 chronicles per week + maxChronicles := 5 + if len(arcs) < maxChronicles { + maxChronicles = len(arcs) + } + + for i := 0; i < maxChronicles; i++ { + arc := arcs[i] + + var post BlogPost + var err error + + // Try to generate LLM narrative + if llmClient != nil && llmClient.baseURL != "" { + post, err = generateLLMChronicle(ctx, arc, data, llmClient) + if err != nil { + // Fall back to template-based chronicle + post = generateTemplateChronicle(arc, data) + } + } else { + // No LLM client, use template + post = generateTemplateChronicle(arc, data) + } + + chronicles = append(chronicles, post) + } + + return chronicles +} + +// generateLLMChronicle creates a chronicle using LLM narrative generation +func generateLLMChronicle(ctx context.Context, arc StoryArc, data *IndexData, llmClient *LLMClient) (BlogPost, error) { + seasonName := getCurrentSeasonName(data) + + req := NarrativeRequest{ + ArcType: arc.Type, + BotName: arc.BotName, + SeasonName: seasonName, + RatingStart: arc.RatingStart, + RatingEnd: arc.RatingEnd, + KeyMatches: arc.KeyMatches, + Archetype: arc.Archetype, + Origin: arc.Origin, + ParentIDs: arc.ParentIDs, + Generation: arc.Generation, + BotBName: arc.BotBName, + } + + // Get rivalry-specific data + if arc.Type == ArcRivalry { + req.BotAWins = arc.BotAWins + req.BotBWins = arc.BotBWins + req.TotalMatches = arc.TotalMatches + } + + headline, narrative, err := llmClient.GenerateNarrative(ctx, req) + if err != nil { + return BlogPost{}, err + } + + slug := fmt.Sprintf("%s-%s-%s", arc.Type, arc.BotID, formatSlugDate(data.GeneratedAt)) + if arc.Type == ArcRivalry { + slug = fmt.Sprintf("rivalry-%s-%s", arc.BotID[:8], arc.BotBID[:8]) + } else if arc.Type == ArcUpset { + slug = fmt.Sprintf("upset-%s-%s", arc.MatchID[:8], formatSlugDate(data.GeneratedAt)) + } + + tags := []string{string(arc.Type)} + if arc.BotID != "" { + tags = append(tags, arc.BotID) + } + if arc.BotBID != "" { + tags = append(tags, arc.BotBID) + } + + return BlogPost{ + Slug: slug, + Title: headline, + Date: data.GeneratedAt.Format("2006-01-02"), + Type: "chronicle", + ContentMd: "# " + headline + "\n\n" + narrative, + Summary: truncateSummary(narrative, 150), + Tags: tags, + }, nil +} + +// generateTemplateChronicle creates a chronicle using templates (fallback) +func generateTemplateChronicle(arc StoryArc, data *IndexData) BlogPost { + switch arc.Type { + case ArcRise: + bot := findBotByID(arc.BotID, data) + if bot != nil { + return generateRiseChronicle(*bot, data) + } + case ArcUpset: + upset := UpsetData{ + MatchID: arc.MatchID, + WinnerID: arc.BotID, + LoserID: arc.BotBID, + WinnerScore: arc.RatingStart, + LoserScore: arc.RatingEnd, + } + return generateUpsetChronicle(upset, data) + case ArcRivalry: + rivalry := RivalryData{ + BotAID: arc.BotID, + BotBID: arc.BotBID, + BotAWins: arc.BotAWins, + BotBWins: arc.BotBWins, + TotalMatches: arc.TotalMatches, + } + return generateRivalryChronicle(rivalry, data) + } + + // Generic fallback + return BlogPost{ + Slug: fmt.Sprintf("%s-%s-%s", arc.Type, arc.BotID, formatSlugDate(data.GeneratedAt)), + Title: fmt.Sprintf("%s: %s", arc.Type, arc.BotName), + Date: data.GeneratedAt.Format("2006-01-02"), + Type: "chronicle", + ContentMd: fmt.Sprintf("# %s: %s\n\nDetails pending.", arc.Type, arc.BotName), + Summary: fmt.Sprintf("Story arc: %s involving %s", arc.Type, arc.BotName), + Tags: []string{string(arc.Type), arc.BotID}, + } +} + +// truncateSummary truncates a string to maxLen characters +func truncateSummary(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + // Find last space before maxLen + lastSpace := maxLen + for i := maxLen - 1; i >= 0; i-- { + if s[i] == ' ' { + lastSpace = i + break + } + } + return s[:lastSpace] + "..." +} + +// findBotByID finds a bot by ID in the data +func findBotByID(id string, data *IndexData) *BotData { + for i := range data.Bots { + if data.Bots[i].ID == id { + return &data.Bots[i] + } + } + return nil +} + // generateRiseChronicle creates a "rising star" story func generateRiseChronicle(bot BotData, data *IndexData) BlogPost { content := fmt.Sprintf(`# The Rise of %s diff --git a/cmd/acb-index-builder/config.go b/cmd/acb-index-builder/config.go index 7631f20..1715790 100644 --- a/cmd/acb-index-builder/config.go +++ b/cmd/acb-index-builder/config.go @@ -40,6 +40,10 @@ type Config struct { // Output directory for generated files OutputDir string + + // LLM configuration for narrative generation + LLMBaseURL string + LLMAPIKey string } // LoadConfig reads configuration from environment variables diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go index b2ec35a..7352b60 100644 --- a/cmd/acb-index-builder/db.go +++ b/cmd/acb-index-builder/db.go @@ -22,28 +22,33 @@ type BotData struct { Evolved bool `json:"evolved"` Island string `json:"island,omitempty"` Generation int `json:"generation,omitempty"` + Archetype string `json:"archetype,omitempty"` + ParentIDs []string `json:"parent_ids,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // MatchData represents a match for the index type MatchData struct { - ID string `json:"id"` - MapID string `json:"map_id"` - WinnerID string `json:"winner_id,omitempty"` - TurnCount int `json:"turn_count"` - EndCondition string `json:"end_condition"` - Participants []MatchParticipant `json:"participants"` - CreatedAt time.Time `json:"created_at"` - CompletedAt time.Time `json:"completed_at"` + ID string `json:"id"` + MapID string `json:"map_id"` + MapName string `json:"map_name,omitempty"` + WinnerID string `json:"winner_id,omitempty"` + TurnCount int `json:"turn_count"` + EndCondition string `json:"end_condition"` + Participants []ParticipantData `json:"participants"` + CreatedAt time.Time `json:"created_at"` + CompletedAt time.Time `json:"completed_at"` + PlayedAt time.Time `json:"played_at"` } -// MatchParticipant represents a bot in a match -type MatchParticipant struct { - BotID string `json:"bot_id"` - PlayerSlot int `json:"player_slot"` - Score int `json:"score"` - Won bool `json:"won"` +// ParticipantData represents a bot in a match with pre-match rating +type ParticipantData struct { + BotID string `json:"bot_id"` + PlayerSlot int `json:"player_slot"` + Score int `json:"score"` + Won bool `json:"won"` + PreMatchRating float64 `json:"pre_match_rating,omitempty"` } // RatingHistoryEntry represents a rating history point @@ -171,6 +176,7 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) { rating_mu, rating_phi, rating_sigma, 0, 0, status, evolved, island, generation, + COALESCE(archetype, ''), COALESCE(parent_ids, '[]'::json), created_at, COALESCE(last_active, created_at) FROM bots WHERE status != 'retired' @@ -188,12 +194,14 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) { var b BotData var desc, island sql.NullString var gen sql.NullInt64 + var parentIDsJSON []byte err := rows.Scan( &b.ID, &b.Name, &b.OwnerID, &desc, &b.Rating, &b.RatingDeviation, &b.RatingVolatility, &b.MatchesPlayed, &b.MatchesWon, &b.HealthStatus, &b.Evolved, &island, &gen, + &b.Archetype, &parentIDsJSON, &b.CreatedAt, &b.UpdatedAt, ) if err != nil { @@ -209,6 +217,9 @@ func fetchBots(ctx context.Context, db *sql.DB) ([]BotData, error) { if gen.Valid { b.Generation = int(gen.Int64) } + if len(parentIDsJSON) > 0 { + json.Unmarshal(parentIDsJSON, &b.ParentIDs) + } bots = append(bots, b) } diff --git a/cmd/acb-index-builder/narrative.go b/cmd/acb-index-builder/narrative.go new file mode 100644 index 0000000..cfbaf35 --- /dev/null +++ b/cmd/acb-index-builder/narrative.go @@ -0,0 +1,726 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// StoryArcType represents the type of narrative arc +type StoryArcType string + +const ( + ArcRise StoryArcType = "rise" + ArcFall StoryArcType = "fall" + ArcRivalry StoryArcType = "rivalry" + ArcUpset StoryArcType = "upset" + ArcEvolutionMilestone StoryArcType = "evolution" + ArcComeback StoryArcType = "comeback" + ArcSeasonRecap StoryArcType = "season-recap" +) + +// StoryArc represents a detected narrative arc +type StoryArc struct { + Type StoryArcType `json:"type"` + BotID string `json:"bot_id,omitempty"` + BotName string `json:"bot_name,omitempty"` + BotBID string `json:"bot_b_id,omitempty"` + BotBName string `json:"bot_b_name,omitempty"` + RatingStart int `json:"rating_start,omitempty"` + RatingEnd int `json:"rating_end,omitempty"` + MatchID string `json:"match_id,omitempty"` + SeasonName string `json:"season_name,omitempty"` + + // Context for LLM prompt + KeyMatches []KeyMatch `json:"key_matches,omitempty"` + Archetype string `json:"archetype,omitempty"` + Origin string `json:"origin,omitempty"` + ParentIDs []string `json:"parent_ids,omitempty"` + Generation int `json:"generation,omitempty"` + CommunityHint string `json:"community_hint,omitempty"` +} + +// KeyMatch represents a key match for narrative context +type KeyMatch struct { + MatchID string `json:"match_id"` + OpponentID string `json:"opponent_id"` + OpponentName string `json:"opponent_name"` + OpponentRating int `json:"opponent_rating"` + MapName string `json:"map_name,omitempty"` + Score string `json:"score"` + TurnCount int `json:"turn_count"` + Won bool `json:"won"` +} + +// LLMClient handles narrative generation via LLM +type LLMClient struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewLLMClient creates a new LLM client for narrative generation +func NewLLMClient(baseURL, apiKey string) *LLMClient { + return &LLMClient{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +// NarrativeRequest contains context for generating a narrative +type NarrativeRequest struct { + ArcType StoryArcType + BotName string + SeasonName string + RatingStart int + RatingEnd int + KeyMatches []KeyMatch + Archetype string + Origin string + ParentIDs []string + Generation int + // Additional context + BotBName string + BotAWins int + BotBWins int + TotalMatches int +} + +// GenerateNarrative generates a 200-word sports-journalism narrative +func (c *LLMClient) GenerateNarrative(ctx context.Context, req NarrativeRequest) (headline, narrative string, err error) { + prompt := buildNarrativePrompt(req) + + response, err := c.chatCompletion(ctx, prompt) + if err != nil { + return "", "", fmt.Errorf("llm request: %w", err) + } + + // Parse response - first line is headline, rest is narrative + lines := strings.Split(strings.TrimSpace(response), "\n") + if len(lines) < 2 { + return "AI Code Battle Chronicle", response, nil + } + + headline = strings.TrimPrefix(lines[0], "# ") + headline = strings.TrimSpace(headline) + narrative = strings.Join(lines[1:], "\n") + narrative = strings.TrimSpace(narrative) + + return headline, narrative, nil +} + +func buildNarrativePrompt(req NarrativeRequest) string { + var sb strings.Builder + + sb.WriteString("Write a 200-word sports-journalism narrative about this event in the AI Code Battle platform. Be dramatic but factual. Reference specific matches. Write in present tense. Do not use emojis.\n\n") + + switch req.ArcType { + case ArcRise: + sb.WriteString(fmt.Sprintf("Arc type: Rise\n")) + sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("Rating: %d โ†’ %d over 7 days\n", req.RatingStart, req.RatingEnd)) + if len(req.KeyMatches) > 0 { + sb.WriteString("Key matches:\n") + for _, m := range req.KeyMatches { + outcome := "Lost to" + if m.Won { + outcome = "Beat" + } + sb.WriteString(fmt.Sprintf(" - %s %s (#%d, %d) on %q โ€” score %s, turn %d\n", + outcome, m.OpponentName, m.OpponentRating/10, m.OpponentRating, m.MapName, m.Score, m.TurnCount)) + } + } + if req.Archetype != "" { + sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype)) + } + if req.Origin != "" { + sb.WriteString(fmt.Sprintf("Origin: %s\n", req.Origin)) + } + + case ArcFall: + sb.WriteString(fmt.Sprintf("Arc type: Fall\n")) + sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("Rating: %d โ†’ %d over 7 days\n", req.RatingStart, req.RatingEnd)) + if len(req.KeyMatches) > 0 { + sb.WriteString("Recent losses:\n") + for _, m := range req.KeyMatches { + sb.WriteString(fmt.Sprintf(" - Lost to %s (#%d) on %q โ€” score %s, turn %d\n", + m.OpponentName, m.OpponentRating/10, m.MapName, m.Score, m.TurnCount)) + } + } + + case ArcRivalry: + sb.WriteString(fmt.Sprintf("Arc type: Rivalry Intensifies\n")) + sb.WriteString(fmt.Sprintf("Bots: %s vs %s\n", req.BotName, req.BotBName)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("Head-to-head record: %d-%d (%d matches this week)\n", + req.BotAWins, req.BotBWins, req.TotalMatches)) + if len(req.KeyMatches) > 0 { + sb.WriteString("Recent encounters:\n") + for _, m := range req.KeyMatches { + outcome := "lost" + if m.Won { + outcome = "won" + } + sb.WriteString(fmt.Sprintf(" - %s %s against %s (%s)\n", + req.BotName, outcome, m.OpponentName, m.Score)) + } + } + + case ArcUpset: + sb.WriteString(fmt.Sprintf("Arc type: Upset of the Week\n")) + sb.WriteString(fmt.Sprintf("Underdog: %s (rating %d)\n", req.BotName, req.RatingStart)) + sb.WriteString(fmt.Sprintf("Favorite: %s (rating %d)\n", req.BotBName, req.RatingEnd)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + if len(req.KeyMatches) > 0 { + m := req.KeyMatches[0] + sb.WriteString(fmt.Sprintf("Match: Final score %s after %d turns on %q\n", + m.Score, m.TurnCount, m.MapName)) + } + + case ArcEvolutionMilestone: + sb.WriteString(fmt.Sprintf("Arc type: Evolution Milestone\n")) + sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("New all-time-high rating: %d\n", req.RatingEnd)) + sb.WriteString(fmt.Sprintf("Origin: %s, generation %d\n", req.Origin, req.Generation)) + if len(req.ParentIDs) > 0 { + sb.WriteString(fmt.Sprintf("Parents: %s\n", strings.Join(req.ParentIDs, ", "))) + } + if req.Archetype != "" { + sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype)) + } + + case ArcComeback: + sb.WriteString(fmt.Sprintf("Arc type: Comeback\n")) + sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("Rating recovery: %d โ†’ %d (after declining to %d)\n", + req.RatingStart, req.RatingEnd, req.RatingStart-150)) + if len(req.KeyMatches) > 0 { + sb.WriteString("Turning point matches:\n") + for _, m := range req.KeyMatches { + sb.WriteString(fmt.Sprintf(" - Beat %s (#%d) โ€” score %s\n", + m.OpponentName, m.OpponentRating/10, m.Score)) + } + } + } + + return sb.String() +} + +type llmChatRequest struct { + Model string `json:"model"` + Messages []llmChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +type llmChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type llmChatResponse struct { + Choices []struct { + Message llmChatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (c *LLMClient) chatCompletion(ctx context.Context, prompt string) (string, error) { + body, err := json.Marshal(llmChatRequest{ + Model: "GLM-5-Turbo", // Use fast tier for cheap narrative generation + Messages: []llmChatMessage{ + {Role: "user", Content: prompt}, + }, + MaxTokens: 500, // ~200 words should fit easily + }) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + url := c.baseURL + "/v1/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + var cr llmChatResponse + if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + + if cr.Error != nil { + return "", fmt.Errorf("llm api error: %s", cr.Error.Message) + } + if len(cr.Choices) == 0 { + return "", fmt.Errorf("llm api returned no choices") + } + + return cr.Choices[0].Message.Content, nil +} + +// detectStoryArcs scans data for narrative arcs per plan ยง15.5 +func detectStoryArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + // Rise: Bot gained >=200 rating in last 7 days + arcs = append(arcs, detectRiseArcs(data)...) + + // Fall: Bot lost >=200 rating in last 7 days + arcs = append(arcs, detectFallArcs(data)...) + + // Rivalry Intensifies: 5+ matches this week with alternating wins + arcs = append(arcs, detectRivalryArcs(data)...) + + // Upset of the Week: Biggest rating gap where underdog won + arcs = append(arcs, detectUpsetArcs(data)...) + + // Evolution Milestone: Evolved bot reached new ATH or entered top 5 + arcs = append(arcs, detectEvolutionArcs(data)...) + + // Comeback: Bot recovered >=150 rating after decline + arcs = append(arcs, detectComebackArcs(data)...) + + return arcs +} + +func detectRiseArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + for _, bot := range data.Bots { + // Check if bot has rating history showing >=200 point gain + if len(getBotRatingHistory(bot.ID, data)) < 2 { + continue + } + + // Find rating from 7 days ago + now := data.GeneratedAt + sevenDaysAgo := now.AddDate(0, 0, -7) + + var oldRating float64 + var foundOld bool + for _, rh := range getBotRatingHistory(bot.ID, data) { + if rh.RecordedAt.Before(sevenDaysAgo) || rh.RecordedAt.Equal(sevenDaysAgo) { + oldRating = rh.Rating + foundOld = true + } + } + + if !foundOld { + continue + } + + currentRating := bot.Rating + ratingGain := currentRating - oldRating + + if ratingGain >= 200 { + arcs = append(arcs, StoryArc{ + Type: ArcRise, + BotID: bot.ID, + BotName: bot.Name, + RatingStart: int(oldRating), + RatingEnd: int(currentRating), + KeyMatches: extractKeyMatches(bot.ID, data), + Archetype: bot.Archetype, + }) + } + } + + return arcs +} + +func detectFallArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + for _, bot := range data.Bots { + if len(getBotRatingHistory(bot.ID, data)) < 2 { + continue + } + + now := data.GeneratedAt + sevenDaysAgo := now.AddDate(0, 0, -7) + + var oldRating float64 + var foundOld bool + for _, rh := range getBotRatingHistory(bot.ID, data) { + if rh.RecordedAt.Before(sevenDaysAgo) || rh.RecordedAt.Equal(sevenDaysAgo) { + oldRating = rh.Rating + foundOld = true + } + } + + if !foundOld { + continue + } + + currentRating := bot.Rating + ratingLoss := oldRating - currentRating + + if ratingLoss >= 200 { + arcs = append(arcs, StoryArc{ + Type: ArcFall, + BotID: bot.ID, + BotName: bot.Name, + RatingStart: int(oldRating), + RatingEnd: int(currentRating), + KeyMatches: extractKeyMatches(bot.ID, data), + }) + } + } + + return arcs +} + +func detectRivalryArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + // Count matches between bot pairs this week + pairMatches := make(map[string][]MatchData) + + now := data.GeneratedAt + weekAgo := now.AddDate(0, 0, -7) + + for _, m := range data.Matches { + if m.PlayedAt.Before(weekAgo) { + continue + } + if len(m.Participants) < 2 { + continue + } + + for i, p1 := range m.Participants { + for _, p2 := range m.Participants[i+1:] { + key := fmt.Sprintf("%s-%s", minStr(p1.BotID, p2.BotID), maxStr(p1.BotID, p2.BotID)) + pairMatches[key] = append(pairMatches[key], m) + } + } + } + + // Find pairs with 5+ matches and alternating wins + for key, matches := range pairMatches { + if len(matches) < 5 { + continue + } + + // Parse bot IDs from key + parts := strings.Split(key, "-") + if len(parts) != 2 { + continue + } + botAID, botBID := parts[0], parts[1] + + // Count wins for each bot and check alternation + botAWins := 0 + botBWins := 0 + alternating := true + lastWinner := "" + + for _, m := range matches { + var winnerID string + for _, p := range m.Participants { + if p.Won { + winnerID = p.BotID + if p.BotID == botAID { + botAWins++ + } else if p.BotID == botBID { + botBWins++ + } + break + } + } + if lastWinner != "" && winnerID == lastWinner { + alternating = false + } + lastWinner = winnerID + } + + // Only include if wins are reasonably close (not one-sided) + if botAWins >= 2 && botBWins >= 2 { + arcs = append(arcs, StoryArc{ + Type: ArcRivalry, + BotID: botAID, + BotName: getBotName(botAID, data), + BotBID: botBID, + BotBName: getBotName(botBID, data), + BotAWins: botAWins, + BotBWins: botBWins, + TotalMatches: len(matches), + KeyMatches: extractRivalryMatches(botAID, botBID, data), + }) + } + } + + return arcs +} + +func detectUpsetArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + var biggestUpset *StoryArc + var biggestGap int + + for _, m := range data.Matches { + if len(m.Participants) < 2 { + continue + } + + // Find winner and loser + var winner, loser *ParticipantData + for i := range m.Participants { + if m.Participants[i].Won { + winner = &m.Participants[i] + } else { + loser = &m.Participants[i] + } + } + + if winner == nil || loser == nil { + continue + } + + // Check if underdog won (winner had lower rating) + gap := loser.PreMatchRating - winner.PreMatchRating + if gap > biggestGap { + biggestGap = gap + biggestUpset = &StoryArc{ + Type: ArcUpset, + BotID: winner.BotID, + BotName: getBotName(winner.BotID, data), + BotBID: loser.BotID, + BotBName: getBotName(loser.BotID, data), + RatingStart: int(winner.PreMatchRating), + RatingEnd: int(loser.PreMatchRating), + MatchID: m.ID, + KeyMatches: []KeyMatch{{ + MatchID: m.ID, + OpponentID: loser.BotID, + OpponentName: getBotName(loser.BotID, data), + OpponentRating: int(loser.PreMatchRating), + MapName: m.MapName, + Score: fmt.Sprintf("%d-%d", winner.Score, loser.Score), + TurnCount: m.TurnCount, + Won: true, + }}, + } + } + } + + if biggestUpset != nil && biggestGap >= 100 { // Minimum 100 rating gap to count as upset + arcs = append(arcs, *biggestUpset) + } + + return arcs +} + +func detectEvolutionArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + for _, bot := range data.Bots { + if !bot.Evolved { + continue + } + + // Check if bot reached new all-time-high rating + var previousATH float64 + for _, rh := range getBotRatingHistory(bot.ID, data) { + if rh.Rating > previousATH && rh.RecordedAt.Before(data.GeneratedAt.AddDate(0, 0, -1)) { + previousATH = rh.Rating + } + } + + // Current rating exceeds previous ATH by significant margin + if bot.Rating > previousATH+50 { + arcs = append(arcs, StoryArc{ + Type: ArcEvolutionMilestone, + BotID: bot.ID, + BotName: bot.Name, + RatingEnd: int(bot.Rating), + Origin: fmt.Sprintf("evolved, %s island", bot.Island), + Generation: bot.Generation, + ParentIDs: bot.ParentIDs, + Archetype: bot.Archetype, + KeyMatches: extractKeyMatches(bot.ID, data), + }) + } + + // Check if bot entered top 5 + rank := getBotRank(bot.ID, data) + if rank > 0 && rank <= 5 { + arcs = append(arcs, StoryArc{ + Type: ArcEvolutionMilestone, + BotID: bot.ID, + BotName: bot.Name, + RatingEnd: int(bot.Rating), + Origin: fmt.Sprintf("evolved, %s island, generation %d", bot.Island, bot.Generation), + Generation: bot.Generation, + ParentIDs: bot.ParentIDs, + Archetype: bot.Archetype, + }) + } + } + + return arcs +} + +func detectComebackArcs(data *IndexData) []StoryArc { + arcs := make([]StoryArc, 0) + + for _, bot := range data.Bots { + if len(getBotRatingHistory(bot.ID, data)) < 3 { + continue + } + + // Find a decline followed by recovery + currentRating := bot.Rating + var peakRating, troughRating float64 + var foundDecline, foundRecovery bool + + // Walk through history to find decline and recovery pattern + for i, rh := range getBotRatingHistory(bot.ID, data) { + if rh.Rating > peakRating { + peakRating = rh.Rating + } + if i > 0 && rh.Rating < getBotRatingHistory(bot.ID, data)[i-1].Rating { + if rh.Rating < troughRating || troughRating == 0 { + troughRating = rh.Rating + foundDecline = true + } + } + } + + // Check if current rating represents recovery of >=150 from trough + if foundDecline && currentRating >= troughRating+150 { + foundRecovery = true + } + + if foundRecovery { + arcs = append(arcs, StoryArc{ + Type: ArcComeback, + BotID: bot.ID, + BotName: bot.Name, + RatingStart: int(peakRating), + RatingEnd: int(currentRating), + KeyMatches: extractKeyMatches(bot.ID, data), + }) + } + } + + return arcs +} + +func extractKeyMatches(botID string, data *IndexData) []KeyMatch { + matches := make([]KeyMatch, 0, 3) + + for _, m := range data.Matches { + var botPart *ParticipantData + var oppPart *ParticipantData + + for i := range m.Participants { + if m.Participants[i].BotID == botID { + botPart = &m.Participants[i] + } else { + oppPart = &m.Participants[i] + } + } + + if botPart == nil || oppPart == nil { + continue + } + + matches = append(matches, KeyMatch{ + MatchID: m.ID, + OpponentID: oppPart.BotID, + OpponentName: getBotName(oppPart.BotID, data), + OpponentRating: int(oppPart.PreMatchRating), + MapName: m.MapName, + Score: fmt.Sprintf("%d-%d", botPart.Score, oppPart.Score), + TurnCount: m.TurnCount, + Won: botPart.Won, + }) + + if len(matches) >= 3 { + break + } + } + + return matches +} + +func extractRivalryMatches(botAID, botBID string, data *IndexData) []KeyMatch { + matches := make([]KeyMatch, 0, 5) + + for _, m := range data.Matches { + var botAPart, botBPart *ParticipantData + + for i := range m.Participants { + if m.Participants[i].BotID == botAID { + botAPart = &m.Participants[i] + } else if m.Participants[i].BotID == botBID { + botBPart = &m.Participants[i] + } + } + + if botAPart == nil || botBPart == nil { + continue + } + + matches = append(matches, KeyMatch{ + MatchID: m.ID, + OpponentID: botBID, + OpponentName: getBotName(botBID, data), + OpponentRating: int(botBPart.PreMatchRating), + MapName: m.MapName, + Score: fmt.Sprintf("%d-%d", botAPart.Score, botBPart.Score), + TurnCount: m.TurnCount, + Won: botAPart.Won, + }) + + if len(matches) >= 5 { + break + } + } + + return matches +} + +func getBotRank(botID string, data *IndexData) int { + for i, bot := range data.Bots { + if bot.ID == botID { + return i + 1 + } + } + return 0 +} + + +// getBotRatingHistory returns rating history entries for a specific bot +func getBotRatingHistory(botID string, data *IndexData) []RatingHistoryEntry { + entries := make([]RatingHistoryEntry, 0) + for _, rh := range data.RatingHistory { + if rh.BotID == botID { + entries = append(entries, rh) + } + } + return entries +} diff --git a/cmd/acb-index-builder/narrative_test.go b/cmd/acb-index-builder/narrative_test.go new file mode 100644 index 0000000..6c0803b --- /dev/null +++ b/cmd/acb-index-builder/narrative_test.go @@ -0,0 +1,318 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestBuildNarrativePrompt_Rise(t *testing.T) { + req := NarrativeRequest{ + ArcType: ArcRise, + BotName: "TestBot", + SeasonName: "Season 4", + RatingStart: 1200, + RatingEnd: 1450, + KeyMatches: []KeyMatch{ + {MatchID: "m1", OpponentName: "TopBot", OpponentRating: 1800, MapName: "The Labyrinth", Score: "3-2", TurnCount: 200, Won: true}, + }, + Archetype: "aggressive", + Origin: "evolved, go island, generation 5", + } + + prompt := buildNarrativePrompt(req) + + if !strings.Contains(prompt, "Arc type: Rise") { + t.Error("prompt should contain arc type") + } + if !strings.Contains(prompt, "TestBot") { + t.Error("prompt should contain bot name") + } + if !strings.Contains(prompt, "1200") || !strings.Contains(prompt, "1450") { + t.Error("prompt should contain rating range") + } + if !strings.Contains(prompt, "Season 4") { + t.Error("prompt should contain season name") + } +} + +func TestBuildNarrativePrompt_Upset(t *testing.T) { + req := NarrativeRequest{ + ArcType: ArcUpset, + BotName: "UnderdogBot", + BotBName: "FavoriteBot", + RatingStart: 1100, + RatingEnd: 1800, + KeyMatches: []KeyMatch{ + {MatchID: "m2", OpponentName: "FavoriteBot", OpponentRating: 1800, MapName: "Open Field", Score: "4-3", TurnCount: 150, Won: true}, + }, + } + + prompt := buildNarrativePrompt(req) + + if !strings.Contains(prompt, "Upset of the Week") { + t.Error("prompt should contain upset arc type") + } + if !strings.Contains(prompt, "UnderdogBot") { + t.Error("prompt should contain underdog name") + } + if !strings.Contains(prompt, "FavoriteBot") { + t.Error("prompt should contain favorite name") + } +} + +func TestBuildNarrativePrompt_Rivalry(t *testing.T) { + req := NarrativeRequest{ + ArcType: ArcRivalry, + BotName: "SwarmBot", + BotBName: "HunterBot", + BotAWins: 5, + BotBWins: 4, + TotalMatches: 9, + SeasonName: "Season 4", + } + + prompt := buildNarrativePrompt(req) + + if !strings.Contains(prompt, "Rivalry Intensifies") { + t.Error("prompt should contain rivalry arc type") + } + if !strings.Contains(prompt, "SwarmBot") || !strings.Contains(prompt, "HunterBot") { + t.Error("prompt should contain both bot names") + } + if !strings.Contains(prompt, "5-4") { + t.Error("prompt should contain head-to-head record") + } +} + +func TestBuildNarrativePrompt_Evolution(t *testing.T) { + req := NarrativeRequest{ + ArcType: ArcEvolutionMilestone, + BotName: "evo-go-g31", + SeasonName: "Season 4", + RatingEnd: 1580, + Origin: "evolved, go island", + Generation: 31, + ParentIDs: []string{"evo-go-g28", "evo-go-g25"}, + Archetype: "hybrid swarm-gatherer", + } + + prompt := buildNarrativePrompt(req) + + if !strings.Contains(prompt, "Evolution Milestone") { + t.Error("prompt should contain evolution milestone arc type") + } + if !strings.Contains(prompt, "evo-go-g31") { + t.Error("prompt should contain bot name") + } + if !strings.Contains(prompt, "generation 31") { + t.Error("prompt should contain generation") + } +} + +func TestBuildNarrativePrompt_Comeback(t *testing.T) { + req := NarrativeRequest{ + ArcType: ArcComeback, + BotName: "ComebackBot", + SeasonName: "Season 4", + RatingStart: 1300, + RatingEnd: 1450, + } + + prompt := buildNarrativePrompt(req) + + if !strings.Contains(prompt, "Comeback") { + t.Error("prompt should contain comeback arc type") + } + if !strings.Contains(prompt, "1300") { + t.Error("prompt should contain rating recovery") + } +} + +func TestTruncateSummary(t *testing.T) { + tests := []struct { + input string + maxLen int + expected string + }{ + {"Short text", 50, "Short text"}, + {"This is exactly fifty chars long, no more, no less.", 50, "This is exactly fifty chars long, no more, no..."}, + {"A very long piece of text that needs to be truncated", 20, "A very long piece..."}, + } + + for _, tc := range tests { + result := truncateSummary(tc.input, tc.maxLen) + if result != tc.expected { + t.Errorf("truncateSummary(%q, %d) = %q, want %q", tc.input, tc.maxLen, result, tc.expected) + } + } +} + +func TestGetBotRatingHistory(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + RatingHistory: []RatingHistoryEntry{ + {BotID: "bot1", Rating: 1000, RecordedAt: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot1", Rating: 1100, RecordedAt: time.Date(2024, 3, 22, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot1", Rating: 1200, RecordedAt: time.Date(2024, 3, 25, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot1", Rating: 1300, RecordedAt: time.Date(2024, 3, 28, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot2", Rating: 1500, RecordedAt: time.Date(2024, 3, 28, 12, 0, 0, 0, time.UTC)}, + }, + } + + history := getBotRatingHistory("bot1", data) + if len(history) != 4 { + t.Errorf("expected 4 history entries for bot1, got %d", len(history)) + } + + history = getBotRatingHistory("bot2", data) + if len(history) != 1 { + t.Errorf("expected 1 history entry for bot2, got %d", len(history)) + } + + history = getBotRatingHistory("nonexistent", data) + if len(history) != 0 { + t.Errorf("expected 0 history entries for nonexistent bot, got %d", len(history)) + } +} + +func TestDetectRiseArcs(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "bot1", Name: "RisingBot", Rating: 1500}, + {ID: "bot2", Name: "StableBot", Rating: 1200}, + }, + RatingHistory: []RatingHistoryEntry{ + // bot1 rose from 1200 to 1500 (300 point gain = rise arc) + {BotID: "bot1", Rating: 1200, RecordedAt: time.Date(2024, 3, 22, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot1", Rating: 1500, RecordedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC)}, + // bot2 only moved 50 points (no arc) + {BotID: "bot2", Rating: 1150, RecordedAt: time.Date(2024, 3, 22, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot2", Rating: 1200, RecordedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC)}, + }, + } + + arcs := detectRiseArcs(data) + if len(arcs) != 1 { + t.Errorf("expected 1 rise arc, got %d", len(arcs)) + } + if len(arcs) > 0 && arcs[0].BotName != "RisingBot" { + t.Errorf("expected rise arc for RisingBot, got %s", arcs[0].BotName) + } +} + +func TestDetectFallArcs(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "bot1", Name: "FallingBot", Rating: 1000}, + }, + RatingHistory: []RatingHistoryEntry{ + // bot1 fell from 1300 to 1000 (300 point loss = fall arc) + {BotID: "bot1", Rating: 1300, RecordedAt: time.Date(2024, 3, 22, 12, 0, 0, 0, time.UTC)}, + {BotID: "bot1", Rating: 1000, RecordedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC)}, + }, + } + + arcs := detectFallArcs(data) + if len(arcs) != 1 { + t.Errorf("expected 1 fall arc, got %d", len(arcs)) + } +} + +func TestDetectRivalryArcs(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "bot1", Name: "SwarmBot"}, + {ID: "bot2", Name: "HunterBot"}, + }, + Matches: []MatchData{ + {ID: "m1", Participants: []ParticipantData{ + {BotID: "bot1", Won: true}, + {BotID: "bot2", Won: false}, + }, PlayedAt: time.Date(2024, 3, 25, 12, 0, 0, 0, time.UTC)}, + {ID: "m2", Participants: []ParticipantData{ + {BotID: "bot1", Won: false}, + {BotID: "bot2", Won: true}, + }, PlayedAt: time.Date(2024, 3, 26, 12, 0, 0, 0, time.UTC)}, + {ID: "m3", Participants: []ParticipantData{ + {BotID: "bot1", Won: true}, + {BotID: "bot2", Won: false}, + }, PlayedAt: time.Date(2024, 3, 27, 12, 0, 0, 0, time.UTC)}, + {ID: "m4", Participants: []ParticipantData{ + {BotID: "bot1", Won: false}, + {BotID: "bot2", Won: true}, + }, PlayedAt: time.Date(2024, 3, 28, 12, 0, 0, 0, time.UTC)}, + {ID: "m5", Participants: []ParticipantData{ + {BotID: "bot1", Won: true}, + {BotID: "bot2", Won: false}, + }, PlayedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC)}, + }, + } + + arcs := detectRivalryArcs(data) + if len(arcs) == 0 { + t.Error("expected at least 1 rivalry arc with 5 matches between bots") + } +} + +// Mock LLM client for testing +type mockLLMClient struct { + response string + err error +} + +func (m *mockLLMClient) GenerateNarrative(ctx context.Context, req NarrativeRequest) (headline, narrative string, err error) { + if m.err != nil { + return "", "", m.err + } + return "Test Headline", m.response, nil +} + +func TestGenerateLLMChronicle_Success(t *testing.T) { + data := &IndexData{ + GeneratedAt: time.Date(2024, 3, 29, 12, 0, 0, 0, time.UTC), + Bots: []BotData{ + {ID: "bot1", Name: "TestBot", Rating: 1500}, + }, + } + + arc := StoryArc{ + Type: ArcRise, + BotID: "bot1", + BotName: "TestBot", + RatingStart: 1200, + RatingEnd: 1500, + } + + // Test with nil LLM client (should fall back to template) + post := generateTemplateChronicle(arc, data) + if post.Title == "" { + t.Error("expected non-empty title from template chronicle") + } + if !strings.Contains(post.ContentMd, "TestBot") { + t.Error("expected chronicle to mention TestBot") + } +} + +func TestGenerateBlogPost(t *testing.T) { + post := BlogPost{ + Slug: "test-post", + Title: "Test Post", + Date: "2024-03-29", + Type: "chronicle", + ContentMd: "# Test\n\nContent here.", + Summary: "Test summary", + Tags: []string{"test"}, + } + + if post.Slug != "test-post" { + t.Errorf("unexpected slug: %s", post.Slug) + } + if len(post.Tags) != 1 { + t.Errorf("expected 1 tag, got %d", len(post.Tags)) + } +}