diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 1cb9b2c..e9d34b2 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -9e566acf921bd92497a9b85cf3b558ce8739e5e7 +e1b21309cc6d91f6ccbfca96cdd965ab924a027f diff --git a/cmd/acb-index-builder/blog.go b/cmd/acb-index-builder/blog.go index 14de339..eee64c4 100644 --- a/cmd/acb-index-builder/blog.go +++ b/cmd/acb-index-builder/blog.go @@ -42,6 +42,21 @@ type BlogEntry struct { Tags []string `json:"tags"` } +// WeeklyChronicle represents the weekly aggregated chronicle file +// per plan §15.5 - written to data/blog/chronicles-YYYY-WW.json +type WeeklyChronicle struct { + Year int `json:"year"` + WeekNumber int `json:"week_number"` + GeneratedAt string `json:"generated_at"` + SeasonName string `json:"season_name"` + StoryArcs []StoryArc `json:"story_arcs"` + Narrative string `json:"narrative"` + MatchCount int `json:"match_count"` + BotCount int `json:"bot_count"` + TopBotName string `json:"top_bot_name"` + TopBotRating float64 `json:"top_bot_rating"` +} + // generateBlog creates blog posts and the blog index. // Meta reports are only generated on Monday or if 7+ days have passed since the last one. func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient, cfg *Config) error { @@ -70,6 +85,12 @@ func generateBlog(data *IndexData, outputDir string, llmClient *LLMClient, cfg * chronicles := generateLLMChronicles(context.Background(), data, llmClient) posts = append(posts, chronicles...) + // Generate weekly aggregated chronicles file + if err := generateWeeklyChronicleFile(context.Background(), data, llmClient, blogDir); err != nil { + slog.Error("Failed to generate weekly chronicles file", "error", err) + // Non-fatal - continue with rest of build + } + // Write individual post files entries := make([]BlogEntry, 0, len(posts)) for _, post := range posts { @@ -2571,3 +2592,221 @@ func formatLookingAhead(movers []eloMover, strats []strategyCount, evoHighlights return sb.String() } + +// ─── Weekly Chronicles File Generation ──────────────────────────────────────────── + +// generateWeeklyChronicleFile creates the weekly aggregated chronicles file +// at data/blog/chronicles-YYYY-WW.json per plan §15.5. +func generateWeeklyChronicleFile(ctx context.Context, data *IndexData, llmClient *LLMClient, blogDir string) error { + now := data.GeneratedAt + year, weekNum := now.ISOWeek() + + // Detect story arcs for the week + arcs := detectStoryArcs(data) + + // Gather context data + topMovers := findTopELOMovers(data, 5) + strategies := calculateDominantStrategies(data) + bestMatch := findMostWatchedMatch(data) + + dominantStrat := "" + if len(strategies) > 0 { + dominantStrat = strategies[0].Archetype + } + + topBotName := "" + topBotRating := 0.0 + if len(data.Bots) > 0 { + topBotName = data.Bots[0].Name + topBotRating = data.Bots[0].Rating + } + + // Count matches this week + weekAgo := now.AddDate(0, 0, -7) + matchCount := 0 + for _, m := range data.Matches { + if m.PlayedAt.After(weekAgo) { + matchCount++ + } + } + + // Build the request + req := WeeklyChroniclesRequest{ + Year: year, + WeekNumber: weekNum, + SeasonName: getCurrentSeasonName(data), + StoryArcs: arcs, + MatchCount: matchCount, + BotCount: len(data.Bots), + TopBotName: topBotName, + TopBotRating: topBotRating, + TopMovers: topMovers, + DominantStrat: dominantStrat, + BestMatch: bestMatch, + } + + // Generate narrative (with LLM if available, otherwise use template) + var narrative string + if llmClient != nil && llmClient.baseURL != "" { + generated, err := llmClient.GenerateWeeklyChronicles(ctx, req) + if err != nil { + slog.Warn("LLM weekly chronicles generation failed, using template", "error", err) + narrative = buildTemplateWeeklyChronicle(req) + } else { + narrative = generated + } + } else { + narrative = buildTemplateWeeklyChronicle(req) + } + + // Build the weekly chronicle struct + chronicle := WeeklyChronicle{ + Year: year, + WeekNumber: weekNum, + GeneratedAt: now.Format(time.RFC3339), + SeasonName: req.SeasonName, + StoryArcs: arcs, + Narrative: narrative, + MatchCount: matchCount, + BotCount: len(data.Bots), + TopBotName: topBotName, + TopBotRating: topBotRating, + } + + // Write to data/blog/chronicles-YYYY-WW.json + filename := fmt.Sprintf("chronicles-%d-%02d.json", year, weekNum) + chroniclePath := filepath.Join(blogDir, filename) + + if err := writeJSON(chroniclePath, chronicle); err != nil { + return fmt.Errorf("write weekly chronicle file: %w", err) + } + + slog.Info("Generated weekly chronicles file", + "filename", filename, + "year", year, + "week", weekNum, + "story_arcs", len(arcs), + "narrative_length", len(narrative)) + + return nil +} + +// buildTemplateWeeklyChronicle creates a template-based weekly narrative +// when LLM generation is unavailable. +func buildTemplateWeeklyChronicle(req WeeklyChroniclesRequest) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("# Week %d Chronicles\n\n", req.WeekNumber)) + sb.WriteString(fmt.Sprintf("## %s\n\n", req.SeasonName)) + + // Lead paragraph + if req.TopBotName != "" { + sb.WriteString(fmt.Sprintf("**%s** sits atop the leaderboard at %.0f ELO. ", req.TopBotName, req.TopBotRating)) + } + + sb.WriteString(fmt.Sprintf("This week saw %d matches across %d active bots.\n\n", req.MatchCount, req.BotCount)) + + // Top movers section + if len(req.TopMovers) > 0 { + sb.WriteString("## ELO Movement\n\n") + for _, m := range req.TopMovers { + dir := "rose" + if m.Delta < 0 { + dir = "fell" + } + sb.WriteString(fmt.Sprintf("- **%s** %s %.0f points (%.0f → %.0f)", m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating)) + if m.Archetype != "" { + sb.WriteString(fmt.Sprintf(" [%s]", m.Archetype)) + } + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // Story arcs by type + riseArcs := filterArcsByType(req.StoryArcs, ArcRise) + fallArcs := filterArcsByType(req.StoryArcs, ArcFall) + rivalryArcs := filterArcsByType(req.StoryArcs, ArcRivalry) + upsetArcs := filterArcsByType(req.StoryArcs, ArcUpset) + evoArcs := filterArcsByType(req.StoryArcs, ArcEvolutionMilestone) + + if len(riseArcs) > 0 { + sb.WriteString("## Rising Stars\n\n") + for _, arc := range riseArcs { + delta := arc.RatingEnd - arc.RatingStart + sb.WriteString(fmt.Sprintf("**%s** climbed %d points this week, moving from %d to %d ELO", + arc.BotName, delta, arc.RatingStart, arc.RatingEnd)) + if arc.Archetype != "" { + sb.WriteString(fmt.Sprintf(" on %s strategy", arc.Archetype)) + } + sb.WriteString(".\n") + } + sb.WriteString("\n") + } + + if len(fallArcs) > 0 { + sb.WriteString("## Falling Behind\n\n") + for _, arc := range fallArcs { + delta := arc.RatingStart - arc.RatingEnd + sb.WriteString(fmt.Sprintf("**%s** dropped %d points (%d → %d ELO).\n", + arc.BotName, delta, arc.RatingStart, arc.RatingEnd)) + } + sb.WriteString("\n") + } + + if len(rivalryArcs) > 0 { + sb.WriteString("## Intensifying Rivalries\n\n") + for _, arc := range rivalryArcs { + sb.WriteString(fmt.Sprintf("**%s vs %s**: %d-%d record over %d matches.\n", + arc.BotName, arc.BotBName, arc.BotAWins, arc.BotBWins, arc.TotalMatches)) + } + sb.WriteString("\n") + } + + if len(upsetArcs) > 0 { + sb.WriteString("## Upsets of the Week\n\n") + for _, arc := range upsetArcs { + gap := arc.RatingEnd - arc.RatingStart + sb.WriteString(fmt.Sprintf("**%s** upset **%s** despite a %d-point ELO disadvantage.\n", + arc.BotName, arc.BotBName, gap)) + } + sb.WriteString("\n") + } + + if len(evoArcs) > 0 { + sb.WriteString("## Evolution Milestones\n\n") + for _, arc := range evoArcs { + sb.WriteString(fmt.Sprintf("**%s** (generation %d", arc.BotName, arc.Generation)) + if arc.Archetype != "" { + sb.WriteString(fmt.Sprintf(", %s", arc.Archetype)) + } + sb.WriteString(fmt.Sprintf(") reached %.0f ELO.\n", float64(arc.RatingEnd))) + } + sb.WriteString("\n") + } + + if req.BestMatch != nil { + sb.WriteString("## Match of the Week\n\n") + sb.WriteString(fmt.Sprintf("**%s** — final score %s in %d turns.\n", + req.BestMatch.Description, req.BestMatch.Score, req.BestMatch.TurnCount)) + } + + sb.WriteString("\n---\n\n*Generated automatically by AI Code Battle.*") + + return sb.String() +} + +// WeeklyChroniclesRequest contains context for generating a weekly chronicle +type WeeklyChroniclesRequest struct { + Year int + WeekNumber int + SeasonName string + StoryArcs []StoryArc + MatchCount int + BotCount int + TopBotName string + TopBotRating float64 + TopMovers []eloMover + DominantStrat string + BestMatch *notableMatch +} diff --git a/cmd/acb-index-builder/narrative.go b/cmd/acb-index-builder/narrative.go index 0663bb7..64d030c 100644 --- a/cmd/acb-index-builder/narrative.go +++ b/cmd/acb-index-builder/narrative.go @@ -964,3 +964,181 @@ func getBotRatingHistory(botID string, data *IndexData) []RatingHistoryEntry { } return entries } + +// ─── Weekly Chronicles Generation ──────────────────────────────────────────────── + + +// GenerateWeeklyChronicles creates a ~500-word aggregated narrative for the week +func (c *LLMClient) GenerateWeeklyChronicles(ctx context.Context, req WeeklyChroniclesRequest) (string, error) { + prompt := buildWeeklyChroniclesPrompt(req) + return c.chatCompletion(ctx, prompt) +} + +// buildWeeklyChroniclesPrompt constructs a prompt for weekly aggregated narrative +func buildWeeklyChroniclesPrompt(req WeeklyChroniclesRequest) string { + var sb strings.Builder + + sb.WriteString("Write a 500-word weekly chronicle for AI Code Battle. ") + sb.WriteString("You are a sports journalist covering an emergent bot league — write with the energy and narrative instinct of esports journalism. ") + sb.WriteString("Be dramatic but factual. Reference specific bots, ELO before/after deltas, rivalry context, head-to-head records, critical turning points, and season standings. ") + sb.WriteString("Weave the data into a compelling story — quote scores, cite match IDs, describe strategic moments. ") + sb.WriteString("Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n") + + sb.WriteString(fmt.Sprintf("Week: %d of %d\n", req.WeekNumber, req.Year)) + sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("Competitive landscape: %d active bots, %d matches this week\n", req.BotCount, req.MatchCount)) + + // Top bot and championship race + if req.TopBotName != "" { + sb.WriteString(fmt.Sprintf("\nCurrent #1: %s (ELO %.0f)\n", req.TopBotName, req.TopBotRating)) + } + + // Top ELO movers + if len(req.TopMovers) > 0 { + sb.WriteString("\nTop ELO movers this week:\n") + for _, m := range req.TopMovers { + dir := "surged" + if m.Delta < 0 { + dir = "dropped" + } + arch := nonEmpty(m.Archetype, "unclassified") + sb.WriteString(fmt.Sprintf(" - %s %s %.0f points (%.0f → %.0f) [%s], W%d/L%d\n", + m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, arch, m.MatchesWon, m.MatchesLost)) + } + } + + // Dominant strategy + if req.DominantStrat != "" { + sb.WriteString(fmt.Sprintf("\nDominant archetype: %s\n", req.DominantStrat)) + } + + // Story arcs by type + sb.WriteString("\nStory arcs this week:\n") + + riseArcs := filterArcsByType(req.StoryArcs, ArcRise) + fallArcs := filterArcsByType(req.StoryArcs, ArcFall) + rivalryArcs := filterArcsByType(req.StoryArcs, ArcRivalry) + upsetArcs := filterArcsByType(req.StoryArcs, ArcUpset) + evoArcs := filterArcsByType(req.StoryArcs, ArcEvolutionMilestone) + comebackArcs := filterArcsByType(req.StoryArcs, ArcComeback) + + if len(riseArcs) > 0 { + sb.WriteString("\n### Rising Stars\n") + for _, arc := range riseArcs { + delta := arc.RatingEnd - arc.RatingStart + sb.WriteString(fmt.Sprintf(" - %s: climbed %d points (%d → %d)", arc.BotName, delta, arc.RatingStart, arc.RatingEnd)) + if arc.Archetype != "" { + sb.WriteString(fmt.Sprintf(" [%s]", arc.Archetype)) + } + if arc.Origin != "" { + sb.WriteString(fmt.Sprintf(" (%s)", arc.Origin)) + } + sb.WriteString("\n") + // Add key match context + if len(arc.KeyMatches) > 0 { + m := arc.KeyMatches[0] + outcome := "Beat" + if !m.Won { + outcome = "Lost to" + } + sb.WriteString(fmt.Sprintf(" Key match: %s %s (rating %d) — score %s, %d turns. Match ID: %s\n", + outcome, m.OpponentName, m.OpponentRating, m.Score, m.TurnCount, m.MatchID)) + } + } + } + + if len(fallArcs) > 0 { + sb.WriteString("\n### Falling Behind\n") + for _, arc := range fallArcs { + delta := arc.RatingStart - arc.RatingEnd + sb.WriteString(fmt.Sprintf(" - %s: dropped %d points (%d → %d)", arc.BotName, delta, arc.RatingStart, arc.RatingEnd)) + if arc.Archetype != "" { + sb.WriteString(fmt.Sprintf(" [%s]", arc.Archetype)) + } + sb.WriteString("\n") + } + } + + if len(rivalryArcs) > 0 { + sb.WriteString("\n### Intensifying Rivalries\n") + for _, arc := range rivalryArcs { + sb.WriteString(fmt.Sprintf(" - %s vs %s: %d-%d over %d matches\n", + arc.BotName, arc.BotBName, arc.BotAWins, arc.BotBWins, arc.TotalMatches)) + // Add recent encounters + if len(arc.KeyMatches) > 0 { + sb.WriteString(" Recent matches: ") + for i, m := range arc.KeyMatches { + if i > 0 { + sb.WriteString(", ") + } + winner := arc.BotBName + if m.Won { + winner = arc.BotName + } + sb.WriteString(fmt.Sprintf("%s won %d-%d", winner, m.TurnCount, m.TurnCount)) + } + sb.WriteString("\n") + } + } + } + + if len(upsetArcs) > 0 { + sb.WriteString("\n### Upsets of the Week\n") + for _, arc := range upsetArcs { + gap := arc.RatingEnd - arc.RatingStart + sb.WriteString(fmt.Sprintf(" - %s upset %s: %d-point ELO gap", arc.BotName, arc.BotBName, gap)) + if len(arc.KeyMatches) > 0 { + m := arc.KeyMatches[0] + sb.WriteString(fmt.Sprintf(", score %s in %d turns. Match ID: %s\n", m.Score, m.TurnCount, m.MatchID)) + } else { + sb.WriteString("\n") + } + } + } + + if len(evoArcs) > 0 { + sb.WriteString("\n### Evolution Milestones\n") + for _, arc := range evoArcs { + sb.WriteString(fmt.Sprintf(" - %s: generation %d evolved bot", arc.BotName, arc.Generation)) + if arc.Archetype != "" { + sb.WriteString(fmt.Sprintf(" [%s]", arc.Archetype)) + } + if arc.Origin != "" { + sb.WriteString(fmt.Sprintf(" (%s)", arc.Origin)) + } + sb.WriteString(fmt.Sprintf(", rating %.0f\n", float64(arc.RatingEnd))) + } + } + + if len(comebackArcs) > 0 { + sb.WriteString("\n### Comebacks\n") + for _, arc := range comebackArcs { + sb.WriteString(fmt.Sprintf(" - %s: from trough back to %.0f after falling from %.0f\n", + arc.BotName, float64(arc.RatingEnd), float64(arc.RatingStart))) + } + } + + // Match of the week + if req.BestMatch != nil { + sb.WriteString(fmt.Sprintf("\n### Match of the Week\n")) + sb.WriteString(fmt.Sprintf("%s — score %s in %d turns. Match ID: %s\n", + req.BestMatch.Description, req.BestMatch.Score, req.BestMatch.TurnCount, req.BestMatch.MatchID)) + } + + sb.WriteString("\nWrite a compelling 500-word weekly chronicle that weaves these story arcs together. ") + sb.WriteString("Focus on the most dramatic narratives — the rise, the fall, the rivalries, the upsets. ") + sb.WriteString("Make it feel like a sports recap that makes readers want to watch the replays.") + + return sb.String() +} + +// filterArcsByType returns arcs of a specific type +func filterArcsByType(arcs []StoryArc, arcType StoryArcType) []StoryArc { + filtered := make([]StoryArc, 0) + for _, arc := range arcs { + if arc.Type == arcType { + filtered = append(filtered, arc) + } + } + return filtered +}