Phase 10 Narrative Engine Implementation
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
217b3ce51d
commit
44544622ae
6 changed files with 1249 additions and 19 deletions
13
PROGRESS.md
13
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
726
cmd/acb-index-builder/narrative.go
Normal file
726
cmd/acb-index-builder/narrative.go
Normal file
|
|
@ -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
|
||||
}
|
||||
318
cmd/acb-index-builder/narrative_test.go
Normal file
318
cmd/acb-index-builder/narrative_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue