fix(index-builder): correct function name typo in weekly chronicles generation

This commit is contained in:
jedarden 2026-05-04 03:10:29 -04:00
parent 6bfd3e6679
commit 8e33ee1f27
3 changed files with 418 additions and 1 deletions

View file

@ -1 +1 @@
9e566acf921bd92497a9b85cf3b558ce8739e5e7
e1b21309cc6d91f6ccbfca96cdd965ab924a027f

View file

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

View file

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