From a1f52f3c4679a86081689d04cc382f4bffc057b9 Mon Sep 17 00:00:00 2001 From: nditz Date: Thu, 10 Sep 2026 20:32:07 +0200 Subject: [PATCH] fix ai prompting and remove worldcup leftovers --- .gitignore | 3 + .../Admin/AdminEndpointIntegrationTests.cs | 34 + .../CompetitionFocusTests.cs | 49 + .../FootballBanterConfigTests.cs | 18 + .../FootballGifQueryTests.cs | 1 + .../GifLibraryServiceTests.cs | 3 + .../BanterApp.Api.Tests/PromptCatalogTests.cs | 76 + .../PunditMediaItemServiceTests.cs | 30 + .../Pundits/PunditExtractionJobTests.cs | 42 + .../WorldCupLegacyPurgeTests.cs | 72 + backend/BanterApp.Api/Data/AppDbContext.cs | 9 + .../BanterApp.Api/Data/CompetitionFocus.cs | 78 + backend/BanterApp.Api/Data/DatabaseSeeder.cs | 9 + .../Data/Entities/PromptOverride.cs | 21 + ...60910180123_AddPromptOverrides.Designer.cs | 3223 +++++++++++++++++ .../20260910180123_AddPromptOverrides.cs | 38 + .../Migrations/AppDbContextModelSnapshot.cs | 23 +- .../Data/UnsourcedPunditPurge.cs | 15 +- .../BanterApp.Api/Data/WorldCupLegacyPurge.cs | 113 +- .../Features/Admin/AdminEndpoints.cs | 42 + .../Features/Feed/FeedEndpoints.cs | 1 + .../UserPredictions/BanterContextEnricher.cs | 17 +- .../Integrations/Ai/AiOptions.cs | 17 +- .../Integrations/Ai/OpenAiContentGenerator.cs | 33 +- .../Integrations/Ai/PromptCatalog.cs | 207 ++ .../Integrations/Ai/StubContentGenerator.cs | 10 +- .../FootballBanterConfigLoader.cs | 4 +- .../FootballBanter/FootballBanterEngine.cs | 5 +- .../FootballBanter/FootballBanterJson.cs | 12 +- .../Integrations/Media/FootballGifQuery.cs | 34 +- .../Integrations/Media/GifLibraryService.cs | 72 + .../Integrations/Media/YouTubeOptions.cs | 9 +- .../Integrations/News/NewsIngestJob.cs | 5 + .../Integrations/News/RssNewsProvider.cs | 6 + .../Pundits/OpenAiPunditOpinionExtractor.cs | 10 +- .../Pundits/PunditExtractionJob.cs | 7 + .../Pundits/PunditIngestOptions.cs | 4 +- .../Pundits/PunditMediaItemService.cs | 5 + .../ServiceCollectionExtensions.cs | 2 + .../appsettings.Development.json.example | 22 +- backend/BanterApp.Api/appsettings.json | 18 +- .../config/football-banter.config.json | 12 +- .../prompts/football-banter-system-prompt.md | 16 +- frontend/e2e/responsive/helpers.ts | 1 + frontend/public/reactions/big-six.svg | 24 + frontend/public/reactions/clean-sheet.svg | 24 + frontend/public/reactions/cooked-take.svg | 24 + frontend/public/reactions/derby-day.svg | 24 + frontend/public/reactions/last-gasp.svg | 24 + frontend/public/reactions/late-drama.svg | 24 + frontend/public/reactions/missed-sitter.svg | 24 + frontend/public/reactions/ratioed.svg | 24 + frontend/public/reactions/red-carded.svg | 24 + frontend/public/reactions/table-talk.svg | 24 + frontend/public/reactions/var-check.svg | 24 + frontend/src/app/admin/prompts/page.tsx | 150 + .../src/components/admin/admin-nav-items.ts | 1 + frontend/src/hooks/admin/useAdmin.ts | 22 + frontend/src/lib/admin/types.ts | 11 + 59 files changed, 4785 insertions(+), 91 deletions(-) create mode 100644 backend/BanterApp.Api.Tests/CompetitionFocusTests.cs create mode 100644 backend/BanterApp.Api.Tests/PromptCatalogTests.cs create mode 100644 backend/BanterApp.Api/Data/CompetitionFocus.cs create mode 100644 backend/BanterApp.Api/Data/Entities/PromptOverride.cs create mode 100644 backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.Designer.cs create mode 100644 backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.cs create mode 100644 backend/BanterApp.Api/Integrations/Ai/PromptCatalog.cs create mode 100644 frontend/public/reactions/big-six.svg create mode 100644 frontend/public/reactions/clean-sheet.svg create mode 100644 frontend/public/reactions/cooked-take.svg create mode 100644 frontend/public/reactions/derby-day.svg create mode 100644 frontend/public/reactions/last-gasp.svg create mode 100644 frontend/public/reactions/late-drama.svg create mode 100644 frontend/public/reactions/missed-sitter.svg create mode 100644 frontend/public/reactions/ratioed.svg create mode 100644 frontend/public/reactions/red-carded.svg create mode 100644 frontend/public/reactions/table-talk.svg create mode 100644 frontend/public/reactions/var-check.svg create mode 100644 frontend/src/app/admin/prompts/page.tsx diff --git a/.gitignore b/.gitignore index fae1aff..df8b9cc 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ npm-debug.log* # Supabase supabase/.temp/ +# One-off local/ops SQL and helper scripts (not part of the app) +scripts/ + # Local-only notes (integration failure reports, scratch docs) docs/local_docs/ diff --git a/backend/BanterApp.Api.Tests/Admin/AdminEndpointIntegrationTests.cs b/backend/BanterApp.Api.Tests/Admin/AdminEndpointIntegrationTests.cs index cb9104c..db015cb 100644 --- a/backend/BanterApp.Api.Tests/Admin/AdminEndpointIntegrationTests.cs +++ b/backend/BanterApp.Api.Tests/Admin/AdminEndpointIntegrationTests.cs @@ -111,6 +111,38 @@ public async Task GetLaunchChecklist_WithAdmin_ReturnsChecklistItems() Assert.Contains(body!.Items, i => i.Label.Contains("OPENAI_API_KEY", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task GetPrompts_WithAdmin_ReturnsShippedKeys() + { + using var client = _factory.CreateAdminClient(); + + var response = await client.GetAsync("/api/admin/prompts"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.Contains(body!, p => p.Key == "FeedBanterSystemPrompt"); + Assert.Contains(body!, p => p.Key == "PunditExtractionSystemPrompt"); + Assert.Equal(5, body!.Count); + } + + [Fact] + public async Task PutPrompt_WithAdminAndCsrf_StoresOverride() + { + using var factory = new BanterAppWebApplicationFactory(); + using var client = factory.CreateAdminClient(); + await CsrfTestHelper.ApplyCsrfAsync(client); + + var response = await client.PutAsJsonAsync( + "/api/admin/prompts/NewsReactionSystemPrompt", + new { body = "Admin override for news reactions." }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var saved = await response.Content.ReadFromJsonAsync(); + Assert.True(saved!.IsOverride); + Assert.Contains("Admin override", saved.Body, StringComparison.Ordinal); + } + [Fact] public async Task GetSession_WithAdmin_ReturnsIsPlatformAdminTrue() { @@ -148,4 +180,6 @@ private sealed record LaunchChecklistResponse(List Items); private sealed record ChecklistItem(string Label, bool Passed); private sealed record SessionAdminResponse(bool IsPlatformAdmin); + + private sealed record PromptListItem(string Key, string Body, bool IsOverride); } diff --git a/backend/BanterApp.Api.Tests/CompetitionFocusTests.cs b/backend/BanterApp.Api.Tests/CompetitionFocusTests.cs new file mode 100644 index 0000000..c60d3a5 --- /dev/null +++ b/backend/BanterApp.Api.Tests/CompetitionFocusTests.cs @@ -0,0 +1,49 @@ +using BanterApp.Api.Data; +using Xunit; + +namespace BanterApp.Api.Tests; + +public class CompetitionFocusTests +{ + [Fact] + public void DisplayName_IsPremierLeagueSeason() + { + Assert.Equal("Premier League", CompetitionFocus.Name); + Assert.Contains("2026/27", CompetitionFocus.DisplayName, StringComparison.Ordinal); + Assert.Contains("Premier League", CompetitionFocus.PromptDirective, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Ignore World Cup", CompetitionFocus.PromptDirective, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("World Cup hydration breaks", true)] + [InlineData("https://www.fifa.com/tournaments/mens/worldcup/qatar2022", true)] + [InlineData("https://www.theguardian.com/football/world-cup/2026", true)] + [InlineData("Arsenal vs Chelsea Premier League preview", false)] + [InlineData("FIFA 26 career mode", false)] + [InlineData("", false)] + public void LooksLikeOffFocus_DetectsWorldCupNotGenericFifa(string value, bool expected) + { + Assert.Equal(expected, CompetitionFocus.LooksLikeOffFocus(value)); + } + + [Fact] + public void ApplyToSystemPrompt_PrependsDirectiveOnce() + { + var first = CompetitionFocus.ApplyToSystemPrompt("Write banter."); + var second = CompetitionFocus.ApplyToSystemPrompt(first); + + Assert.StartsWith(CompetitionFocus.PromptDirective, first, StringComparison.Ordinal); + Assert.Equal(first, second); + } + + [Fact] + public void YouTubeSearchQueries_ArePremierLeagueSpecific() + { + Assert.NotEmpty(CompetitionFocus.YouTubeSearchQueries); + Assert.All(CompetitionFocus.YouTubeSearchQueries, q => + { + Assert.Contains("Premier League", q, StringComparison.OrdinalIgnoreCase); + Assert.False(CompetitionFocus.LooksLikeOffFocus(q)); + }); + } +} diff --git a/backend/BanterApp.Api.Tests/FootballBanterConfigTests.cs b/backend/BanterApp.Api.Tests/FootballBanterConfigTests.cs index 7e31255..5701a03 100644 --- a/backend/BanterApp.Api.Tests/FootballBanterConfigTests.cs +++ b/backend/BanterApp.Api.Tests/FootballBanterConfigTests.cs @@ -136,6 +136,7 @@ public async Task GenerateAsync_PreservesSourceAttribution() var engine = new FootballBanterEngine( config, new StubFootballBanterContentGenerator(), + new UnusedPromptCatalog(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var output = await engine.GenerateAsync(new FootballBanterSourceInput @@ -234,4 +235,21 @@ public Task GenerateFootballBanterJsonAsync( public Task GenerateUsernameSuggestionAsync(CancellationToken cancellationToken = default) => Task.FromResult("TestKnight42"); } + + private sealed class UnusedPromptCatalog : Integrations.Ai.IPromptCatalog + { + public Task ResolveAsync(string key, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Stub OpenAI path should not resolve prompts."); + + public Task> ListAsync( + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Stub OpenAI path should not list prompts."); + + public Task SaveAsync( + string key, + string? body, + Guid? updatedByUserId, + CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Stub OpenAI path should not save prompts."); + } } diff --git a/backend/BanterApp.Api.Tests/FootballGifQueryTests.cs b/backend/BanterApp.Api.Tests/FootballGifQueryTests.cs index abf48dd..fe59a14 100644 --- a/backend/BanterApp.Api.Tests/FootballGifQueryTests.cs +++ b/backend/BanterApp.Api.Tests/FootballGifQueryTests.cs @@ -11,6 +11,7 @@ public class FootballGifQueryTests [InlineData("goal of the season", true)] [InlineData("happy birthday", false)] [InlineData("cat dancing", false)] + [InlineData("world cup celebration", false)] [InlineData("", false)] public void LooksFootball_DetectsFootballPhrases(string phrase, bool expected) { diff --git a/backend/BanterApp.Api.Tests/GifLibraryServiceTests.cs b/backend/BanterApp.Api.Tests/GifLibraryServiceTests.cs index 5bb2946..021f10f 100644 --- a/backend/BanterApp.Api.Tests/GifLibraryServiceTests.cs +++ b/backend/BanterApp.Api.Tests/GifLibraryServiceTests.cs @@ -25,6 +25,9 @@ public async Task EnsureSeededAsync_InsertsBundledAssetsWithDescriptions() Assert.Equal(GifAssetSources.Bundled, a.Source); }); Assert.Contains(await db.GifSearchQueries.Select(q => q.Phrase).ToListAsync(), p => p == "premier league"); + Assert.Contains(assets, a => a.Url == "/reactions/delulu-vision.svg"); + Assert.True(assets.Count >= 20); + Assert.Contains(await db.GifSearchQueries.Select(q => q.Phrase).ToListAsync(), p => p == "haaland celebration"); } [Fact] diff --git a/backend/BanterApp.Api.Tests/PromptCatalogTests.cs b/backend/BanterApp.Api.Tests/PromptCatalogTests.cs new file mode 100644 index 0000000..6d0e676 --- /dev/null +++ b/backend/BanterApp.Api.Tests/PromptCatalogTests.cs @@ -0,0 +1,76 @@ +using BanterApp.Api.Integrations.Ai; +using BanterApp.Api.Tests.Infrastructure; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BanterApp.Api.Tests; + +public class PromptCatalogTests +{ + [Fact] + public async Task ResolveAsync_UsesCodeDefaultWhenTableIsEmpty() + { + await using var db = TestDbContextFactory.Create(); + var catalog = CreateCatalog(db, "shipped default"); + + var resolved = await catalog.ResolveAsync(PromptKeys.NewsReaction); + + Assert.Contains("shipped default", resolved, StringComparison.Ordinal); + Assert.Contains("Premier League", resolved, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ResolveAsync_UsesDatabaseOverrideWithoutRedeploy() + { + await using var db = TestDbContextFactory.Create(); + var catalog = CreateCatalog(db, "shipped default"); + + await catalog.SaveAsync(PromptKeys.NewsReaction, "Live override: talk like a Super Sunday desk.", null); + + var resolved = await catalog.ResolveAsync(PromptKeys.NewsReaction); + + Assert.Contains("Live override", resolved, StringComparison.Ordinal); + Assert.DoesNotContain("shipped default", resolved, StringComparison.Ordinal); + } + + [Fact] + public async Task SaveAsync_EmptyBodyClearsOverride() + { + await using var db = TestDbContextFactory.Create(); + var catalog = CreateCatalog(db, "shipped default"); + await catalog.SaveAsync(PromptKeys.FeedBanter, "temporary override", null); + + var cleared = await catalog.SaveAsync(PromptKeys.FeedBanter, " ", null); + + Assert.False(cleared.IsOverride); + Assert.Equal("shipped default", cleared.Body); + Assert.Contains("shipped default", await catalog.ResolveAsync(PromptKeys.FeedBanter), StringComparison.Ordinal); + } + + [Fact] + public async Task ListAsync_IncludesShippedDefaultsForKnownKeys() + { + await using var db = TestDbContextFactory.Create(); + var catalog = CreateCatalog(db, "shipped default"); + + var list = await catalog.ListAsync(); + + Assert.Equal(PromptKeys.All.Count, list.Count); + Assert.All(list, e => Assert.False(e.IsOverride)); + Assert.Contains(list, e => e.Key == PromptKeys.PunditExtraction); + } + + private static PromptCatalog CreateCatalog(Data.AppDbContext db, string defaultBody) + { + var options = Options.Create(new AiOptions + { + NewsReactionSystemPrompt = defaultBody, + FeedBanterSystemPrompt = defaultBody, + MemeImagePrompt = defaultBody, + FeedVisualSystemPrompt = defaultBody, + PunditExtractionSystemPrompt = defaultBody + }); + return new PromptCatalog(db, options, new MemoryCache(new MemoryCacheOptions())); + } +} diff --git a/backend/BanterApp.Api.Tests/PunditMediaItemServiceTests.cs b/backend/BanterApp.Api.Tests/PunditMediaItemServiceTests.cs index 4dce85c..674328c 100644 --- a/backend/BanterApp.Api.Tests/PunditMediaItemServiceTests.cs +++ b/backend/BanterApp.Api.Tests/PunditMediaItemServiceTests.cs @@ -97,6 +97,36 @@ public async Task UpsertItemAsync_skips_blank_external_id() Assert.Empty(db.MediaItems.Local); } + [Fact] + public async Task UpsertItemAsync_skips_world_cup_titles_and_urls() + { + await using var db = TestDbContextFactory.Create(); + var service = new PunditMediaItemService(db); + var source = await service.EnsureSourceAsync("Feed", "rss", "https://example.com/feed.xml"); + + var byTitle = await service.UpsertItemAsync( + source, + Article("guid-wc", "World Cup hydration breaks"), + CancellationToken.None); + var byUrl = await service.UpsertItemAsync( + source, + new( + "guid-fifa", + "Match preview", + "summary", + "https://www.fifa.com/tournaments/mens/worldcup/preview", + null, + DateTimeOffset.UtcNow, + "https://example.com/feed.xml", + Publication: "FIFA", + FullText: "body"), + CancellationToken.None); + + Assert.Equal((0, 0, 1, false), byTitle); + Assert.Equal((0, 0, 1, false), byUrl); + Assert.Empty(db.MediaItems.Local); + } + private static MediaItemDto Article(string externalId, string title) => new( externalId, diff --git a/backend/BanterApp.Api.Tests/Pundits/PunditExtractionJobTests.cs b/backend/BanterApp.Api.Tests/Pundits/PunditExtractionJobTests.cs index e628802..a99f947 100644 --- a/backend/BanterApp.Api.Tests/Pundits/PunditExtractionJobTests.cs +++ b/backend/BanterApp.Api.Tests/Pundits/PunditExtractionJobTests.cs @@ -121,6 +121,48 @@ r.Metadata is not null && Assert.True(usage.CircuitOpened); } + [Fact] + public async Task ExtractAsync_SkipsWorldCupItemsWithoutCallingOpenAi() + { + await using var db = TestDbContextFactory.Create(); + var source = new MediaSource + { + Id = Guid.NewGuid(), + Name = "Sky", + SourceType = "rss", + ExtractPredictions = true, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }; + db.MediaSources.Add(source); + var item = EnrichedItem(source.Id, "wc"); + item.Title = "World Cup hydration breaks"; + item.SourceUrl = "https://www.theguardian.com/football/world-cup/2026"; + db.MediaItems.Add(item); + await db.SaveChangesAsync(); + + var extractor = new RateLimitExtractor(); + var tracking = new RecordingErrorTracking(); + var usage = new RecordingUsageGuard(); + var job = new PunditExtractionJob( + db, + extractor, + persistence: null!, + Options.Create(new PunditIngestOptions { Enabled = true, ExtractionBatchSize = 5, MinSourceTextLength = 20 }), + new SyncRunTracker(db, tracking, NullLogger.Instance), + new StubRecurringJobs(), + usage, + tracking, + NullLogger.Instance); + + await job.ExtractAsync(CancellationToken.None); + + Assert.Equal(0, extractor.Calls); + var stored = Assert.Single(db.MediaItems); + Assert.Equal(MediaItemProcessingStatus.Skipped, stored.ProcessingStatus); + Assert.Contains("Off-focus", stored.ProcessingError, StringComparison.OrdinalIgnoreCase); + } + private static MediaItem EnrichedItem(Guid sourceId, string suffix) => new() { Id = Guid.NewGuid(), diff --git a/backend/BanterApp.Api.Tests/WorldCupLegacyPurgeTests.cs b/backend/BanterApp.Api.Tests/WorldCupLegacyPurgeTests.cs index 990c928..3f643d5 100644 --- a/backend/BanterApp.Api.Tests/WorldCupLegacyPurgeTests.cs +++ b/backend/BanterApp.Api.Tests/WorldCupLegacyPurgeTests.cs @@ -140,4 +140,76 @@ public async Task RemovesMisStampedApiFootballWorldCupRows() Assert.True(await db.Matches.AnyAsync(m => m.Id == "pl26-mw1-1")); Assert.False(await db.Matches.AnyAsync(m => m.Id.StartsWith("apifb-"))); } + + [Fact] + public async Task RemovesWorldCupMediaItemsAndDeactivatesGifQueries() + { + await using var db = TestDbContextFactory.Create(); + var source = new MediaSource + { + Id = Guid.NewGuid(), + Name = "Sky Sports", + SourceType = "rss", + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }; + var fifaSource = new MediaSource + { + Id = Guid.NewGuid(), + Name = "FIFA World Cup", + SourceType = "rss", + SiteUrl = "https://www.fifa.com/tournaments/mens/worldcup", + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow + }; + db.MediaSources.AddRange(source, fifaSource); + db.MediaItems.AddRange( + new MediaItem + { + Id = Guid.NewGuid(), + MediaSourceId = source.Id, + ExternalId = "pl-1", + Title = "Arsenal vs Chelsea preview", + SourceUrl = "https://www.skysports.com/football/arsenal-chelsea", + Description = "Premier League preview", + ProcessingStatus = MediaItemProcessingStatus.Pending, + LastSyncedAt = DateTimeOffset.UtcNow + }, + new MediaItem + { + Id = Guid.NewGuid(), + MediaSourceId = source.Id, + ExternalId = "wc-1", + Title = "World Cup hydration breaks", + SourceUrl = "https://www.theguardian.com/football/world-cup/2026", + Description = "World Cup notes", + ProcessingStatus = MediaItemProcessingStatus.Pending, + LastSyncedAt = DateTimeOffset.UtcNow + }); + db.GifSearchQueries.Add(new GifSearchQuery + { + Id = Guid.NewGuid(), + Phrase = "world cup celebration", + Source = GifSearchQuerySources.GiphyTrending, + IsFootballRelated = true, + IsActive = true + }); + db.GeneratedContents.Add(new GeneratedContent + { + Id = Guid.NewGuid(), + Type = GeneratedContentType.Banter, + Prompt = "Write World Cup banter", + Output = "England to the semi-finals" + }); + await db.SaveChangesAsync(); + + var removed = await WorldCupLegacyPurge.ExecuteAsync(db); + + Assert.Equal(0, removed); + Assert.Equal("Arsenal vs Chelsea preview", (await db.MediaItems.SingleAsync()).Title); + Assert.False(await db.GeneratedContents.AnyAsync()); + Assert.False((await db.GifSearchQueries.SingleAsync()).IsActive); + Assert.True((await db.MediaSources.SingleAsync(s => s.Name == "Sky Sports")).IsActive); + Assert.False((await db.MediaSources.SingleAsync(s => s.Name == "FIFA World Cup")).IsActive); + } } diff --git a/backend/BanterApp.Api/Data/AppDbContext.cs b/backend/BanterApp.Api/Data/AppDbContext.cs index 79ae4a1..a801611 100644 --- a/backend/BanterApp.Api/Data/AppDbContext.cs +++ b/backend/BanterApp.Api/Data/AppDbContext.cs @@ -28,6 +28,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet ReactionGifUses => Set(); public DbSet GifAssets => Set(); public DbSet GifSearchQueries => Set(); + public DbSet PromptOverrides => Set(); public DbSet BanterContentHistories => Set(); public DbSet TournamentBonusPicks => Set(); public DbSet TournamentAwardResults => Set(); @@ -330,6 +331,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasIndex(x => new { x.IsActive, x.IsFootballRelated, x.LastSeenAtUtc }); }); + modelBuilder.Entity(e => + { + e.ToTable("prompt_overrides"); + e.HasKey(x => x.Key); + e.Property(x => x.Key).HasMaxLength(PromptOverrideLimits.Key); + e.Property(x => x.Body).HasMaxLength(PromptOverrideLimits.Body); + }); + modelBuilder.Entity(e => { e.ToTable("banter_content_history"); diff --git a/backend/BanterApp.Api/Data/CompetitionFocus.cs b/backend/BanterApp.Api/Data/CompetitionFocus.cs new file mode 100644 index 0000000..809c43d --- /dev/null +++ b/backend/BanterApp.Api/Data/CompetitionFocus.cs @@ -0,0 +1,78 @@ +namespace BanterApp.Api.Data; + +/// +/// The live product's active competition. Prompts, YouTube search, GIF queries, and +/// ingest filters read this so World Cup leftovers do not keep driving AI output. +/// Swap when the product adds another league. +/// +public static class CompetitionFocus +{ + public static string Name => PremierLeagueCatalog.Name; + + public static string Season => PremierLeagueCatalog.CurrentSeasonName; + + public static string DisplayName => $"{Name} {Season}"; + + public static string PromptDirective => + $"The product currently covers {DisplayName} only. " + + "Search, extract, and write about that competition. " + + "Ignore World Cup, FIFA tournaments, international friendlies, and other leagues " + + "unless they directly affect a Premier League club this season. " + + "Do not invent World Cup predictions, national-team frames, or Brazil/England tournament tropes."; + + public static string ApplyToSystemPrompt(string systemPrompt) + { + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + return PromptDirective; + } + + if (systemPrompt.Contains(DisplayName, StringComparison.OrdinalIgnoreCase) && + systemPrompt.Contains("Ignore World Cup", StringComparison.OrdinalIgnoreCase)) + { + return systemPrompt; + } + + return PromptDirective + "\n\n" + systemPrompt.Trim(); + } + + public static bool LooksLikeOffFocus(params string?[] values) + { + foreach (var value in values) + { + if (LooksLikeOffFocus(value)) + { + return true; + } + } + + return false; + } + + public static bool LooksLikeOffFocus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var text = value.Trim(); + return text.Contains("world cup", StringComparison.OrdinalIgnoreCase) + || text.Contains("world-cup", StringComparison.OrdinalIgnoreCase) + || text.Contains("worldcup", StringComparison.OrdinalIgnoreCase) + || text.Contains("fifa.com", StringComparison.OrdinalIgnoreCase) + || text.Contains("/world-cup", StringComparison.OrdinalIgnoreCase); + } + + public static IReadOnlyList YouTubeSearchQueries { get; } = + [ + "Premier League 2026/27 predictions pundits", + "Premier League weekend preview predictions", + "Sky Sports Premier League Super Sunday predictions", + "Monday Night Football Premier League predictions", + "The Rest Is Football Premier League", + "Stick to Football Premier League predictions", + "Gary Neville Premier League prediction", + "BBC Match of the Day Premier League analysis" + ]; +} diff --git a/backend/BanterApp.Api/Data/DatabaseSeeder.cs b/backend/BanterApp.Api/Data/DatabaseSeeder.cs index 8164bfa..f2a3af2 100644 --- a/backend/BanterApp.Api/Data/DatabaseSeeder.cs +++ b/backend/BanterApp.Api/Data/DatabaseSeeder.cs @@ -1,6 +1,7 @@ using BanterApp.Api.Common; using BanterApp.Api.Data.Entities; using BanterApp.Api.Features.Matches; +using BanterApp.Api.Integrations.Media; using BanterApp.Api.Integrations.News; using BanterApp.Api.Integrations.Rss; using BanterApp.Api.Integrations.SportsData; @@ -52,6 +53,9 @@ public static async Task SeedAsync(IServiceProvider services, CancellationToken await WorldCupLegacyPurge.ExecuteAsync(db, logger, cancellationToken); + var gifLibrary = scope.ServiceProvider.GetRequiredService(); + await gifLibrary.EnsureSeededAsync(cancellationToken); + var rssCatalog = scope.ServiceProvider.GetRequiredService(); await rssCatalog.SeedAsync(cancellationToken); @@ -66,6 +70,11 @@ public static async Task SeedAsync(IServiceProvider services, CancellationToken var articles = await news.GetLatestArticlesAsync(20, cancellationToken); foreach (var article in articles) { + if (CompetitionFocus.LooksLikeOffFocus(article.Title, article.Url, article.Summary)) + { + continue; + } + db.NewsFeedItems.Add(new NewsFeedItem { Id = article.Id, diff --git a/backend/BanterApp.Api/Data/Entities/PromptOverride.cs b/backend/BanterApp.Api/Data/Entities/PromptOverride.cs new file mode 100644 index 0000000..1a52d79 --- /dev/null +++ b/backend/BanterApp.Api/Data/Entities/PromptOverride.cs @@ -0,0 +1,21 @@ +namespace BanterApp.Api.Data.Entities; + +/// +/// Live override for an AI prompt. Empty table means shipped AiOptions defaults are used. +/// +public class PromptOverride +{ + public string Key { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public Guid? UpdatedByUserId { get; set; } +} + +public static class PromptOverrideLimits +{ + public const int Key = 80; + public const int Body = 32_000; +} diff --git a/backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.Designer.cs b/backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.Designer.cs new file mode 100644 index 0000000..ffdd637 --- /dev/null +++ b/backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.Designer.cs @@ -0,0 +1,3223 @@ +// +using System; +using BanterApp.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BanterApp.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260910180123_AddPromptOverrides")] + partial class AddPromptOverrides + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.19") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.AdminAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AdminUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("TargetId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("CreatedAt"); + + b.ToTable("admin_audit_logs", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.AnonymousUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiGenerationsUsed") + .HasColumnType("integer"); + + b.Property("CookieId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CountryCode") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceFingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecoveryCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TermsAcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("CookieId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("anonymous_users", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.AppMetric", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DimensionsJson") + .HasColumnType("text"); + + b.Property("MetricKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MetricValue") + .HasColumnType("double precision"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MetricKey", "RecordedAt"); + + b.ToTable("app_metrics", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ApplicationErrorLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Detail") + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestMethod") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RequestPath") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("SyncRunId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source"); + + b.ToTable("application_error_logs", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.AuthAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Details") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("EventType"); + + b.HasIndex("OccurredAt"); + + b.ToTable("auth_audit_logs", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.BanterContentHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CaptionHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MatchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MemeTemplateId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PredictionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderContentId") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ScenarioType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SearchPhrase") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SelectionScore") + .HasColumnType("numeric"); + + b.Property("TeamId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MemeTemplateId", "UsedAtUtc"); + + b.HasIndex("Provider", "ProviderContentId"); + + b.HasIndex("ScenarioType", "UsedAtUtc"); + + b.HasIndex("TeamId", "UsedAtUtc"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("banter_content_history", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ClubTeam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderTeamId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ShortName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Provider", "ProviderTeamId"); + + b.ToTable("club_teams", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAvailableForPrediction") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderCompetitionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("competitions", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.CompetitionSeason", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompetitionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderSeasonId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartYear") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsCurrent"); + + b.HasIndex("CompetitionId", "StartYear") + .IsUnique(); + + b.ToTable("competition_seasons", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Continent") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExternalProvider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("FifaRanking") + .HasColumnType("integer"); + + b.Property("FlagUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("IsActive"); + + b.HasIndex("ExternalProvider", "ExternalId") + .IsUnique() + .HasFilter("\"ExternalProvider\" IS NOT NULL AND \"ExternalId\" IS NOT NULL"); + + b.ToTable("countries", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ExternalId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderExternalId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RawPayloadHash") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("Provider", "ProviderExternalId", "EntityType") + .IsUnique(); + + b.ToTable("external_ids", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.GeneratedContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AnonymousUserId"); + + b.HasIndex("UserId"); + + b.ToTable("generated_content", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.GifAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Mood") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Tags") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .IsUnique(); + + b.HasIndex("IsActive", "Mood"); + + b.ToTable("gif_assets", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.GifSearchQuery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsFootballRelated") + .HasColumnType("boolean"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Phrase") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Phrase") + .IsUnique(); + + b.HasIndex("IsActive", "IsFootballRelated", "LastSeenAtUtc"); + + b.ToTable("gif_search_queries", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.IngestionError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaItemId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StackTrace") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SyncRunId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("LastSeenAt"); + + b.HasIndex("Status"); + + b.HasIndex("Source", "JobKey", "Message"); + + b.ToTable("ingestion_errors", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.JobRegistryState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("JobKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Paused") + .HasColumnType("boolean"); + + b.Property("Schedule") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("JobKey") + .IsUnique(); + + b.ToTable("job_registry_state", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.LeaderboardEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Competition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaderboardType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Season") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceProvider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("LeaderboardType", "Competition", "Season", "Rank"); + + b.HasIndex("LeaderboardType", "PlayerId", "Competition", "Season", "SourceProvider") + .IsUnique(); + + b.ToTable("leaderboard_entries", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.League", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByAnonymousUserId") + .HasColumnType("uuid"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MaxMembers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByAnonymousUserId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.HasIndex("Kind", "CountryCode"); + + b.ToTable("leagues", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.LeagueMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeagueId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AnonymousUserId"); + + b.HasIndex("UserId"); + + b.HasIndex("LeagueId", "AnonymousUserId"); + + b.HasIndex("LeagueId", "UserId"); + + b.ToTable("league_members", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.LineupPlayer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsSubstitute") + .HasColumnType("boolean"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PlayerName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Position") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShirtNumber") + .HasColumnType("integer"); + + b.Property("TeamCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.HasIndex("MatchId", "TeamCode", "PlayerName", "IsSubstitute"); + + b.ToTable("lineup_players", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Match", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AwayLogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("AwayScore") + .HasColumnType("integer"); + + b.Property("CompetitionSeasonId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasColumnType("text"); + + b.Property("HomeLogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("HomeScore") + .HasColumnType("integer"); + + b.Property("KickoffTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchweekId") + .HasColumnType("uuid"); + + b.Property("MatchweekNumber") + .HasColumnType("integer"); + + b.Property("PredictionLockAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Stage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamA") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamACode") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamB") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamBCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Venue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionSeasonId"); + + b.HasIndex("KickoffTime"); + + b.HasIndex("MatchweekId"); + + b.HasIndex("MatchweekNumber"); + + b.ToTable("matches", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MatchEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Detail") + .HasColumnType("text"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Minute") + .HasColumnType("integer"); + + b.Property("PlayerName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TeamCode") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("MatchId", "ProviderEventId") + .IsUnique(); + + b.ToTable("match_events", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Matchweek", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompetitionSeasonId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionSeasonId", "Number") + .IsUnique(); + + b.ToTable("matchweeks", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MatchweekBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("AwardedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompetitionSeasonId") + .HasColumnType("uuid"); + + b.Property("MatchweekNumber") + .HasColumnType("integer"); + + b.Property("PointsAwarded") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AnonymousUserId", "CompetitionSeasonId", "MatchweekNumber"); + + b.HasIndex("UserId", "CompetitionSeasonId", "MatchweekNumber"); + + b.ToTable("matchweek_bonuses", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Author") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaSourceId") + .HasColumnType("uuid"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessingError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Publication") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RawPayloadJson") + .HasColumnType("text"); + + b.Property("RawSummary") + .HasColumnType("text"); + + b.Property("RawText") + .HasColumnType("text"); + + b.Property("SourceUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TranscriptSnippet") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ContentHash"); + + b.HasIndex("MediaSourceId", "ExternalId") + .IsUnique(); + + b.HasIndex("ProcessingStatus", "ProcessedAt"); + + b.ToTable("media_items", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigJson") + .HasColumnType("text"); + + b.Property("CrawlAllowed") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExtractPredictions") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("RssUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("media_sources", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.NewsFeedItem", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Author") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("MatchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MediaType") + .HasColumnType("text"); + + b.Property("ParentItemId") + .HasColumnType("text"); + + b.Property("PredictionSummary") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("QualityScore") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MatchId"); + + b.HasIndex("PublishedAt"); + + b.HasIndex("QualityScore"); + + b.ToTable("news_feed_items", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.OperationalError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ErrorCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ErrorType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Fingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("JobRunId") + .HasColumnType("uuid"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageInternal") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MessageSafe") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Method") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ProviderRequestId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RequestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Route") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceItemId") + .HasColumnType("uuid"); + + b.Property("StackTrace") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Fingerprint"); + + b.HasIndex("LastSeenAt"); + + b.HasIndex("RequestId"); + + b.HasIndex("Fingerprint", "Status"); + + b.HasIndex("Severity", "Status"); + + b.HasIndex("Source", "Provider"); + + b.ToTable("errors", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Age") + .HasColumnType("integer"); + + b.Property("ClubName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ExternalId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExternalProvider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("FirstName") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KnownName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("LastName") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("NationalTeamName") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PhotoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Position") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("DisplayName"); + + b.HasIndex("IsActive"); + + b.HasIndex("Position"); + + b.HasIndex("ExternalProvider", "ExternalId") + .IsUnique() + .HasFilter("\"ExternalProvider\" IS NOT NULL AND \"ExternalId\" IS NOT NULL"); + + b.ToTable("players", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PlayerStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Assists") + .HasColumnType("integer"); + + b.Property("Competition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Goals") + .HasColumnType("integer"); + + b.Property("MatchesPlayed") + .HasColumnType("integer"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("MinutesPlayed") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)"); + + b.Property("RedCards") + .HasColumnType("integer"); + + b.Property("Season") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceProvider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YellowCards") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("PlayerId", "CountryId", "Competition", "Season", "SourceProvider") + .IsUnique(); + + b.ToTable("player_stats", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Prediction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchId") + .IsRequired() + .HasColumnType("character varying(64)"); + + b.Property("PointsAwarded") + .HasColumnType("integer"); + + b.Property("PredictionType") + .HasColumnType("integer"); + + b.Property("PredictionValue") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MatchId"); + + b.HasIndex("AnonymousUserId", "MatchId", "PredictionType"); + + b.HasIndex("UserId", "MatchId", "PredictionType"); + + b.ToTable("predictions", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PredictionAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceScore") + .HasColumnType("double precision"); + + b.Property("ConsensusSummary") + .HasColumnType("text"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NegativeCount") + .HasColumnType("integer"); + + b.Property("NeutralCount") + .HasColumnType("integer"); + + b.Property("PositiveCount") + .HasColumnType("integer"); + + b.Property("PredictionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityName", "PredictionType") + .IsUnique(); + + b.ToTable("prediction_aggregates", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PredictionReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("AuraDelta") + .HasColumnType("integer"); + + b.Property("AwayScore") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HomeScore") + .HasColumnType("integer"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MatchStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PointsAwarded") + .HasColumnType("integer"); + + b.Property("PredictionId") + .HasColumnType("uuid"); + + b.Property("PredictionType") + .HasColumnType("integer"); + + b.Property("PredictionValue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PunditTakesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResultHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SettledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StoryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StoryTypesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AnonymousUserId"); + + b.HasIndex("MatchId"); + + b.HasIndex("SettledAt"); + + b.HasIndex("UserId"); + + b.HasIndex("PredictionId", "ResultHash") + .IsUnique(); + + b.ToTable("prediction_receipts", null, t => + { + t.HasCheckConstraint("CK_prediction_receipts_owner", "(\"UserId\" IS NOT NULL AND \"AnonymousUserId\" IS NULL) OR (\"UserId\" IS NULL AND \"AnonymousUserId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PromptOverride", b => + { + b.Property("Key") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(32000) + .HasColumnType("character varying(32000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid"); + + b.HasKey("Key"); + + b.ToTable("prompt_overrides", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ProviderUsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AverageLatencyMs") + .HasColumnType("double precision"); + + b.Property("EstimatedUnits") + .HasColumnType("integer"); + + b.Property("FailureCount") + .HasColumnType("integer"); + + b.Property("LatencySamples") + .HasColumnType("integer"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RequestCount") + .HasColumnType("integer"); + + b.Property("TotalLatencyMs") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsageDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("Provider", "UsageDate") + .IsUnique(); + + b.ToTable("provider_usage_daily", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Pundit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Archetype") + .IsRequired() + .HasColumnType("text"); + + b.Property("AttributionMode") + .HasColumnType("integer"); + + b.Property("AvatarSeed") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Organization") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParodyCue") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("StyleSlug") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Kind"); + + b.HasIndex("NormalizedName"); + + b.ToTable("pundits", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PunditId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PunditId"); + + b.HasIndex("AnonymousUserId", "PunditId") + .IsUnique() + .HasFilter("\"AnonymousUserId\" IS NOT NULL"); + + b.HasIndex("UserId", "PunditId") + .IsUnique() + .HasFilter("\"UserId\" IS NOT NULL"); + + b.ToTable("pundit_follows", null, t => + { + t.HasCheckConstraint("CK_pundit_follows_owner", "(\"UserId\" IS NOT NULL AND \"AnonymousUserId\" IS NULL) OR (\"UserId\" IS NULL AND \"AnonymousUserId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EvidenceQuote") + .HasColumnType("text"); + + b.Property("ExtractedJson") + .HasColumnType("text"); + + b.Property("IsDirectQuote") + .HasColumnType("boolean"); + + b.Property("MatchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MatchName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NeedsHumanReview") + .HasColumnType("boolean"); + + b.Property("Opinion") + .IsRequired() + .HasColumnType("text"); + + b.Property("Player") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Prediction") + .HasColumnType("text"); + + b.Property("PredictionType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PunditId") + .HasColumnType("uuid"); + + b.Property("QuoteContext") + .HasColumnType("text"); + + b.Property("ReviewNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ReviewStatus") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserId") + .HasColumnType("uuid"); + + b.Property("SourceItemId") + .HasColumnType("uuid"); + + b.Property("Team") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Topic") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("MatchId"); + + b.HasIndex("NeedsHumanReview"); + + b.HasIndex("PunditId"); + + b.HasIndex("ReviewStatus"); + + b.HasIndex("SourceItemId"); + + b.HasIndex("Team"); + + b.ToTable("pundit_opinions", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditPrediction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Author") + .HasColumnType("text"); + + b.Property("Confidence") + .HasColumnType("double precision"); + + b.Property("EvidenceSnippet") + .HasColumnType("text"); + + b.Property("IsMatched") + .HasColumnType("boolean"); + + b.Property("MatchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PredictedScore") + .HasColumnType("text"); + + b.Property("PredictedTeam") + .HasColumnType("text"); + + b.Property("Prediction") + .IsRequired() + .HasColumnType("text"); + + b.Property("PredictionType") + .HasColumnType("text"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PunditId") + .HasColumnType("uuid"); + + b.Property("SourceType") + .HasColumnType("text"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Speaker") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MatchId"); + + b.HasIndex("PunditId"); + + b.ToTable("pundit_predictions", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ReactionGifUse", b => + { + b.Property("WindowId") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("GifId") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Seed") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("WindowId", "GifId"); + + b.HasIndex("WindowId", "Seed") + .IsUnique() + .HasFilter("\"Seed\" IS NOT NULL"); + + b.ToTable("reaction_gif_uses", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ReceiptStoryCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("ReceiptId") + .HasColumnType("uuid"); + + b.Property("StoryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "Rank"); + + b.ToTable("receipt_story_candidates", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.RssFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplePodcastId") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractPredictions") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LastCheckedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHttpStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RssUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("StyleSlug") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseForMediaIngest") + .HasColumnType("boolean"); + + b.Property("UseForNews") + .HasColumnType("boolean"); + + b.Property("UseForPundit") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("ApplePodcastId") + .IsUnique() + .HasFilter("\"ApplePodcastId\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("IsActive", "Priority"); + + b.ToTable("rss_feeds", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.SeasonTeam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompetitionSeasonId") + .HasColumnType("uuid"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("CompetitionSeasonId", "TeamId") + .IsUnique(); + + b.ToTable("season_teams", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.StandingRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompetitionSeasonId") + .HasColumnType("uuid"); + + b.Property("Drawn") + .HasColumnType("integer"); + + b.Property("GoalDiff") + .HasColumnType("integer"); + + b.Property("GoalsAgainst") + .HasColumnType("integer"); + + b.Property("GoalsFor") + .HasColumnType("integer"); + + b.Property("GroupKey") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LogoUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Lost") + .HasColumnType("integer"); + + b.Property("Played") + .HasColumnType("integer"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("TeamCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("TeamName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Won") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionSeasonId"); + + b.HasIndex("GroupKey", "TeamCode", "Provider") + .IsUnique(); + + b.ToTable("standing_rows", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.SyncError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntityId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SyncRunId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("SyncRunId"); + + b.ToTable("sync_errors", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.SyncRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsProcessed") + .HasColumnType("integer"); + + b.Property("ItemsSkipped") + .HasColumnType("integer"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecordsCreated") + .HasColumnType("integer"); + + b.Property("RecordsFailed") + .HasColumnType("integer"); + + b.Property("RecordsUpdated") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.HasIndex("Provider", "JobName"); + + b.ToTable("sync_runs", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.TournamentAwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnnouncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AnswerDisplay") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("AnswerValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Category") + .IsUnique(); + + b.ToTable("tournament_award_results", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.TournamentBonusPick", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnonymousUserId") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PickValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PointsAwarded") + .HasColumnType("integer"); + + b.Property("SlotIndex") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AnonymousUserId", "Category", "SlotIndex"); + + b.HasIndex("UserId", "Category", "SlotIndex"); + + b.ToTable("tournament_bonus_picks", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountStatus") + .HasColumnType("integer"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CountryCode") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAdultVerified") + .HasColumnType("boolean"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("TermsAcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountStatus"); + + b.HasIndex("IsPlatformAdmin"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.UserPrediction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Competition") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Confidence") + .HasColumnType("integer"); + + b.Property("CountryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("LockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("PredictionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PredictionValue") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Season") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PredictionType"); + + b.HasIndex("UserId", "PredictionType", "Competition", "Season") + .IsUnique(); + + b.ToTable("user_predictions", (string)null); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.CompetitionSeason", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Competition", "Competition") + .WithMany("Seasons") + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Competition"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.GeneratedContent", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany("GeneratedContents") + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany("GeneratedContents") + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.LeaderboardEntry", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Country", "Country") + .WithMany("LeaderboardEntries") + .HasForeignKey("CountryId"); + + b.HasOne("BanterApp.Api.Data.Entities.Player", "Player") + .WithMany("LeaderboardEntries") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.League", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "CreatedByAnonymousUser") + .WithMany() + .HasForeignKey("CreatedByAnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedByAnonymousUser"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.LeagueMember", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany() + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.League", "League") + .WithMany("Members") + .HasForeignKey("LeagueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany("LeagueMemberships") + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("League"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.LineupPlayer", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Match", "Match") + .WithMany() + .HasForeignKey("MatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Match"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Match", b => + { + b.HasOne("BanterApp.Api.Data.Entities.CompetitionSeason", "CompetitionSeason") + .WithMany("Matches") + .HasForeignKey("CompetitionSeasonId"); + + b.HasOne("BanterApp.Api.Data.Entities.Matchweek", "Matchweek") + .WithMany("Matches") + .HasForeignKey("MatchweekId"); + + b.Navigation("CompetitionSeason"); + + b.Navigation("Matchweek"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MatchEvent", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Match", "Match") + .WithMany() + .HasForeignKey("MatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Match"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Matchweek", b => + { + b.HasOne("BanterApp.Api.Data.Entities.CompetitionSeason", "CompetitionSeason") + .WithMany("Matchweeks") + .HasForeignKey("CompetitionSeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompetitionSeason"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MatchweekBonus", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany() + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MediaItem", b => + { + b.HasOne("BanterApp.Api.Data.Entities.MediaSource", "MediaSource") + .WithMany("Items") + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaSource"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Player", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Country", "Country") + .WithMany("Players") + .HasForeignKey("CountryId"); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PlayerStat", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Country", "Country") + .WithMany("PlayerStats") + .HasForeignKey("CountryId"); + + b.HasOne("BanterApp.Api.Data.Entities.Player", "Player") + .WithMany("Stats") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Prediction", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany("Predictions") + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.Match", "Match") + .WithMany("Predictions") + .HasForeignKey("MatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany("Predictions") + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("Match"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PredictionReceipt", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany() + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.Match", "Match") + .WithMany("Receipts") + .HasForeignKey("MatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.Prediction", "Prediction") + .WithMany("Receipts") + .HasForeignKey("PredictionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("Match"); + + b.Navigation("Prediction"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditFollow", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany() + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.Pundit", "Pundit") + .WithMany("Follows") + .HasForeignKey("PunditId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("Pundit"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditOpinion", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Match", "Match") + .WithMany() + .HasForeignKey("MatchId"); + + b.HasOne("BanterApp.Api.Data.Entities.Pundit", "Pundit") + .WithMany("Opinions") + .HasForeignKey("PunditId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.MediaItem", "SourceItem") + .WithMany("Opinions") + .HasForeignKey("SourceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Match"); + + b.Navigation("Pundit"); + + b.Navigation("SourceItem"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditPrediction", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Match", "Match") + .WithMany("PunditPredictions") + .HasForeignKey("MatchId"); + + b.HasOne("BanterApp.Api.Data.Entities.Pundit", "Pundit") + .WithMany("Predictions") + .HasForeignKey("PunditId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Match"); + + b.Navigation("Pundit"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ReceiptStoryCandidate", b => + { + b.HasOne("BanterApp.Api.Data.Entities.PredictionReceipt", "Receipt") + .WithMany("StoryCandidates") + .HasForeignKey("ReceiptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receipt"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.SeasonTeam", b => + { + b.HasOne("BanterApp.Api.Data.Entities.CompetitionSeason", "CompetitionSeason") + .WithMany("Teams") + .HasForeignKey("CompetitionSeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BanterApp.Api.Data.Entities.ClubTeam", "Team") + .WithMany("SeasonTeams") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompetitionSeason"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.StandingRow", b => + { + b.HasOne("BanterApp.Api.Data.Entities.CompetitionSeason", "CompetitionSeason") + .WithMany() + .HasForeignKey("CompetitionSeasonId"); + + b.Navigation("CompetitionSeason"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.SyncError", b => + { + b.HasOne("BanterApp.Api.Data.Entities.SyncRun", "SyncRun") + .WithMany() + .HasForeignKey("SyncRunId"); + + b.Navigation("SyncRun"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.TournamentBonusPick", b => + { + b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") + .WithMany() + .HasForeignKey("AnonymousUserId"); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("AnonymousUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.UserPrediction", b => + { + b.HasOne("BanterApp.Api.Data.Entities.Country", "Country") + .WithMany("UserPredictions") + .HasForeignKey("CountryId"); + + b.HasOne("BanterApp.Api.Data.Entities.Player", "Player") + .WithMany("UserPredictions") + .HasForeignKey("PlayerId"); + + b.HasOne("BanterApp.Api.Data.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.AnonymousUser", b => + { + b.Navigation("GeneratedContents"); + + b.Navigation("Predictions"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.ClubTeam", b => + { + b.Navigation("SeasonTeams"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Competition", b => + { + b.Navigation("Seasons"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.CompetitionSeason", b => + { + b.Navigation("Matches"); + + b.Navigation("Matchweeks"); + + b.Navigation("Teams"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Country", b => + { + b.Navigation("LeaderboardEntries"); + + b.Navigation("PlayerStats"); + + b.Navigation("Players"); + + b.Navigation("UserPredictions"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.League", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Match", b => + { + b.Navigation("Predictions"); + + b.Navigation("PunditPredictions"); + + b.Navigation("Receipts"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Matchweek", b => + { + b.Navigation("Matches"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MediaItem", b => + { + b.Navigation("Opinions"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.MediaSource", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Player", b => + { + b.Navigation("LeaderboardEntries"); + + b.Navigation("Stats"); + + b.Navigation("UserPredictions"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Prediction", b => + { + b.Navigation("Receipts"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.PredictionReceipt", b => + { + b.Navigation("StoryCandidates"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.Pundit", b => + { + b.Navigation("Follows"); + + b.Navigation("Opinions"); + + b.Navigation("Predictions"); + }); + + modelBuilder.Entity("BanterApp.Api.Data.Entities.User", b => + { + b.Navigation("GeneratedContents"); + + b.Navigation("LeagueMemberships"); + + b.Navigation("Predictions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.cs b/backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.cs new file mode 100644 index 0000000..2e8311a --- /dev/null +++ b/backend/BanterApp.Api/Data/Migrations/20260910180123_AddPromptOverrides.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BanterApp.Api.Data.Migrations +{ + /// + public partial class AddPromptOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "prompt_overrides", + columns: table => new + { + Key = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Body = table.Column(type: "character varying(32000)", maxLength: 32000, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedByUserId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_prompt_overrides", x => x.Key); + }); + + migrationBuilder.Sql("ALTER TABLE public.prompt_overrides ENABLE ROW LEVEL SECURITY;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "prompt_overrides"); + } + } +} diff --git a/backend/BanterApp.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/BanterApp.Api/Data/Migrations/AppDbContextModelSnapshot.cs index 100054e..cca478a 100644 --- a/backend/BanterApp.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/BanterApp.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -1880,6 +1880,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("BanterApp.Api.Data.Entities.PromptOverride", b => + { + b.Property("Key") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(32000) + .HasColumnType("character varying(32000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid"); + + b.HasKey("Key"); + + b.ToTable("prompt_overrides", (string)null); + }); + modelBuilder.Entity("BanterApp.Api.Data.Entities.ProviderUsageDaily", b => { b.Property("Id") @@ -2939,7 +2961,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("BanterApp.Api.Data.Entities.PunditFollow", b => { b.HasOne("BanterApp.Api.Data.Entities.AnonymousUser", "AnonymousUser") diff --git a/backend/BanterApp.Api/Data/UnsourcedPunditPurge.cs b/backend/BanterApp.Api/Data/UnsourcedPunditPurge.cs index aadf50a..c2e804e 100644 --- a/backend/BanterApp.Api/Data/UnsourcedPunditPurge.cs +++ b/backend/BanterApp.Api/Data/UnsourcedPunditPurge.cs @@ -41,9 +41,7 @@ public static async Task ExecuteAsync( .Where(n => n.Category == PunditOpinionFeedMapper.FeedCategory && (feedIds.Contains(n.Id) || - LooksLikeWorldCupText(n.Title) || - LooksLikeWorldCupText(n.Url) || - LooksLikeWorldCupText(n.Summary))) + CompetitionFocus.LooksLikeOffFocus(n.Title, n.Url, n.Summary))) .ToListAsync(cancellationToken); db.NewsFeedItems.RemoveRange(junkFeed); @@ -101,9 +99,7 @@ private static bool IsJunkOpinion(PunditOpinion opinion) var source = opinion.SourceItem; if (source is not null && - (LooksLikeWorldCupText(source.Title) || - LooksLikeWorldCupText(source.SourceUrl) || - LooksLikeWorldCupText(source.Description) || + (CompetitionFocus.LooksLikeOffFocus(source.Title, source.SourceUrl, source.Description) || (source.ProcessingError?.Contains("Transcript incomplete", StringComparison.OrdinalIgnoreCase) ?? false) || SourceTextQuality.IsTitleDescriptionFallback(source.Title, source.Description, source.RawText))) { @@ -112,11 +108,4 @@ private static bool IsJunkOpinion(PunditOpinion opinion) return false; } - - private static bool LooksLikeWorldCupText(string? value) => - !string.IsNullOrWhiteSpace(value) && - (value.Contains("world cup", StringComparison.OrdinalIgnoreCase) || - value.Contains("world-cup", StringComparison.OrdinalIgnoreCase) || - value.Contains("worldcup", StringComparison.OrdinalIgnoreCase) || - value.Contains("fifa", StringComparison.OrdinalIgnoreCase)); } diff --git a/backend/BanterApp.Api/Data/WorldCupLegacyPurge.cs b/backend/BanterApp.Api/Data/WorldCupLegacyPurge.cs index ec00ace..0b44a3c 100644 --- a/backend/BanterApp.Api/Data/WorldCupLegacyPurge.cs +++ b/backend/BanterApp.Api/Data/WorldCupLegacyPurge.cs @@ -1,3 +1,4 @@ +using BanterApp.Api.Data.Entities; using BanterApp.Api.Features.Matches; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -7,7 +8,8 @@ namespace BanterApp.Api.Data; /// /// Removes leftover World Cup 2026 and other non-Premier-League rows so the live /// product is PL-only. Filtering queries is not enough — production still had -/// OpenFootball of26-* fixtures and mis-stamped apifb-* WC rows. +/// OpenFootball of26-* fixtures, mis-stamped apifb-* WC rows, and +/// World Cup media items that kept feeding AI prompts. /// public static class WorldCupLegacyPurge { @@ -40,13 +42,30 @@ await db.PunditOpinions db.Matches.RemoveRange(leftover); } + var wcMedia = await OffFocusMediaItems(db.MediaItems).ToListAsync(cancellationToken); + var wcMediaIds = wcMedia.Select(i => i.Id).ToHashSet(); + if (wcMediaIds.Count > 0) + { + db.PunditOpinions.RemoveRange( + await db.PunditOpinions + .Where(o => wcMediaIds.Contains(o.SourceItemId)) + .ToListAsync(cancellationToken)); + db.MediaItems.RemoveRange(wcMedia); + } + var wcNews = await db.NewsFeedItems .Where(n => (n.MatchId != null && leftoverIds.Contains(n.MatchId)) || n.Title.ToLower().Contains("world cup") || - (n.Summary != null && n.Summary.ToLower().Contains("world cup")) || + n.Title.ToLower().Contains("world-cup") || + n.Title.ToLower().Contains("worldcup") || + (n.Summary != null && ( + n.Summary.ToLower().Contains("world cup") || + n.Summary.ToLower().Contains("world-cup") || + n.Summary.ToLower().Contains("worldcup"))) || n.Url.ToLower().Contains("world-cup") || - n.Url.ToLower().Contains("worldcup")) + n.Url.ToLower().Contains("worldcup") || + n.Url.ToLower().Contains("fifa.com")) .ToListAsync(cancellationToken); var wcNewsIds = wcNews.Select(n => n.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -74,17 +93,101 @@ await db.PunditOpinions player.UpdatedAt = now; } + var wcGifQueries = await db.GifSearchQueries + .Where(q => q.IsActive && ( + q.Phrase.ToLower().Contains("world cup") || + q.Phrase.ToLower().Contains("world-cup") || + q.Phrase.ToLower().Contains("worldcup"))) + .ToListAsync(cancellationToken); + foreach (var query in wcGifQueries) + { + query.IsActive = false; + } + + var wcSources = await db.MediaSources + .Where(s => s.IsActive && ( + s.Name.ToLower().Contains("world cup") || + s.Name.ToLower().Contains("world-cup") || + (s.RssUrl != null && ( + s.RssUrl.ToLower().Contains("world-cup") || + s.RssUrl.ToLower().Contains("worldcup") || + s.RssUrl.ToLower().Contains("fifa.com"))) || + (s.SiteUrl != null && ( + s.SiteUrl.ToLower().Contains("world-cup") || + s.SiteUrl.ToLower().Contains("worldcup") || + s.SiteUrl.ToLower().Contains("fifa.com"))))) + .ToListAsync(cancellationToken); + foreach (var source in wcSources) + { + source.IsActive = false; + source.UpdatedAt = now; + } + + var wcRssFeeds = await db.RssFeeds + .Where(f => f.IsActive && ( + f.Name.ToLower().Contains("world cup") || + f.RssUrl.ToLower().Contains("world-cup") || + f.RssUrl.ToLower().Contains("worldcup") || + f.RssUrl.ToLower().Contains("fifa.com") || + (f.SiteUrl != null && ( + f.SiteUrl.ToLower().Contains("world-cup") || + f.SiteUrl.ToLower().Contains("worldcup") || + f.SiteUrl.ToLower().Contains("fifa.com"))))) + .ToListAsync(cancellationToken); + foreach (var feed in wcRssFeeds) + { + feed.IsActive = false; + feed.UpdatedAt = now; + } + + var wcGenerated = await db.GeneratedContents + .Where(g => + g.Prompt.ToLower().Contains("world cup") || + g.Prompt.ToLower().Contains("world-cup") || + g.Prompt.ToLower().Contains("worldcup") || + g.Output.ToLower().Contains("world cup") || + g.Output.ToLower().Contains("world-cup") || + g.Output.ToLower().Contains("worldcup")) + .ToListAsync(cancellationToken); + db.GeneratedContents.RemoveRange(wcGenerated); + await db.SaveChangesAsync(cancellationToken); - if (leftoverIds.Count > 0 || wcNewsIds.Count > 0 || leftoverPlayers.Count > 0) + if (leftoverIds.Count > 0 || + wcNewsIds.Count > 0 || + leftoverPlayers.Count > 0 || + wcMedia.Count > 0 || + wcGifQueries.Count > 0 || + wcSources.Count > 0 || + wcRssFeeds.Count > 0 || + wcGenerated.Count > 0) { logger?.LogWarning( - "Non-PL / World Cup purge: {Matches} matches, {News} news items, {Players} national-squad players deactivated.", + "Non-PL / World Cup purge: {Matches} matches, {News} news items, {Media} media items, {GifQueries} GIF queries deactivated, {Sources} sources deactivated, {Rss} RSS feeds deactivated, {Generated} generated rows, {Players} national-squad players deactivated.", leftoverIds.Count, wcNewsIds.Count, + wcMedia.Count, + wcGifQueries.Count, + wcSources.Count, + wcRssFeeds.Count, + wcGenerated.Count, leftoverPlayers.Count); } return leftoverIds.Count; } + + private static IQueryable OffFocusMediaItems(IQueryable items) => + items.Where(i => + i.Title.ToLower().Contains("world cup") || + i.Title.ToLower().Contains("world-cup") || + i.Title.ToLower().Contains("worldcup") || + i.SourceUrl.ToLower().Contains("world-cup") || + i.SourceUrl.ToLower().Contains("worldcup") || + i.SourceUrl.ToLower().Contains("fifa.com") || + (i.Description != null && ( + i.Description.ToLower().Contains("world cup") || + i.Description.ToLower().Contains("world-cup") || + i.Description.ToLower().Contains("worldcup") || + i.Description.ToLower().Contains("fifa.com")))); } diff --git a/backend/BanterApp.Api/Features/Admin/AdminEndpoints.cs b/backend/BanterApp.Api/Features/Admin/AdminEndpoints.cs index 81af6ce..08ddd96 100644 --- a/backend/BanterApp.Api/Features/Admin/AdminEndpoints.cs +++ b/backend/BanterApp.Api/Features/Admin/AdminEndpoints.cs @@ -1,6 +1,7 @@ using BanterApp.Api.Common; using BanterApp.Api.Data; using BanterApp.Api.Data.Entities; +using BanterApp.Api.Integrations.Ai; using BanterApp.Api.Integrations.Jobs; using BanterApp.Api.Integrations.Media; using BanterApp.Api.Integrations.News; @@ -75,6 +76,9 @@ public static void MapAdminEndpoints(this WebApplication app) group.MapPost("/backfill/failed-extractions", BackfillFailedExtractions).RequireRateLimiting(RateLimitPolicies.Write); group.MapPost("/backfill/prediction-aggregates", BackfillPredictionAggregates).RequireRateLimiting(RateLimitPolicies.Write); + group.MapGet("/prompts", ListPrompts); + group.MapPut("/prompts/{key}", SavePrompt).RequireRateLimiting(RateLimitPolicies.Write); + group.MapGet("/football-data/overview", GetFootballDataOverview); group.MapGet("/football-data/countries", GetFootballCountries); group.MapGet("/football-data/players", GetFootballPlayers); @@ -735,6 +739,42 @@ private static async Task GetAuditLogs(AppDbContext db, int? limit, Can return Results.Ok(logs); } + private static async Task ListPrompts(IPromptCatalog prompts, CancellationToken ct) => + Results.Ok(await prompts.ListAsync(ct)); + + private static async Task SavePrompt( + string key, + PromptOverrideRequest request, + IPromptCatalog prompts, + IUserContext user, + IAdminAuditService audit, + HttpContext http, + CancellationToken ct) + { + if (!PromptKeys.TryGet(key, out _)) + { + return Results.NotFound(new { error = "Unknown prompt key." }); + } + + try + { + var saved = await prompts.SaveAsync(key, request.Body, user.UserId, ct); + await audit.LogAsync( + user, + http, + string.IsNullOrWhiteSpace(request.Body) ? "prompt.reset" : "prompt.update", + "prompt", + saved.Key, + new { saved.IsOverride, length = saved.Body.Length }, + ct); + return Results.Ok(saved); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + } + private static async Task BackfillRss( IRecurringJobManager recurring, IUserContext user, IAdminAuditService audit, HttpContext http, CancellationToken ct) { @@ -857,3 +897,5 @@ private static async Task SyncAllFootballData( public sealed record AdminReviewRejectRequest(string? Notes); public sealed record SetActiveRequest(bool IsActive); + +public sealed record PromptOverrideRequest(string? Body); diff --git a/backend/BanterApp.Api/Features/Feed/FeedEndpoints.cs b/backend/BanterApp.Api/Features/Feed/FeedEndpoints.cs index 3d1c774..84b3a14 100644 --- a/backend/BanterApp.Api/Features/Feed/FeedEndpoints.cs +++ b/backend/BanterApp.Api/Features/Feed/FeedEndpoints.cs @@ -135,6 +135,7 @@ private static async Task> LoadNewsItemsAsync( var articles = await news.GetLatestArticlesAsync(maxItems, ct); return articles + .Where(a => !CompetitionFocus.LooksLikeOffFocus(a.Title, a.SourceUrl, a.Summary)) .OrderByDescending(a => a.PublishedAt) .Select(a => MapFromDto(a)) .ToList(); diff --git a/backend/BanterApp.Api/Features/UserPredictions/BanterContextEnricher.cs b/backend/BanterApp.Api/Features/UserPredictions/BanterContextEnricher.cs index dedf778..32b8871 100644 --- a/backend/BanterApp.Api/Features/UserPredictions/BanterContextEnricher.cs +++ b/backend/BanterApp.Api/Features/UserPredictions/BanterContextEnricher.cs @@ -27,7 +27,7 @@ public async Task BuildContextJsonAsync(CancellationToken cancellationTo .Select(e => new { player = e.Player.DisplayName, - country = e.Country != null ? e.Country.Name : e.Player.NationalTeamName, + club = e.Player.ClubName, goals = e.Value, rank = e.Rank }) @@ -42,7 +42,7 @@ public async Task BuildContextJsonAsync(CancellationToken cancellationTo .Select(e => new { player = e.Player.DisplayName, - country = e.Country != null ? e.Country.Name : e.Player.NationalTeamName, + club = e.Player.ClubName, assists = e.Value, rank = e.Rank }) @@ -56,7 +56,7 @@ public async Task BuildContextJsonAsync(CancellationToken cancellationTo .Select(s => new { player = s.Player.DisplayName, - country = s.Country != null ? s.Country.Name : s.Player.NationalTeamName, + club = s.Player.ClubName, s.Goals, s.Assists, s.MatchesPlayed @@ -72,6 +72,13 @@ public async Task BuildContextJsonAsync(CancellationToken cancellationTo var context = new { + competition = new + { + name = CompetitionFocus.Name, + season = CompetitionFocus.Season, + display = CompetitionFocus.DisplayName, + focus = CompetitionFocus.PromptDirective + }, top_user_predictions = new[] { new { type = UserPredictionTypes.LeagueWinner, entries = winnerAgg.Entries.Take(3) }, @@ -81,9 +88,9 @@ public async Task BuildContextJsonAsync(CancellationToken cancellationTo top_scorers = topScorers, top_assists = topAssists, player_stats = playerStats, - country_stats = winnerAgg.Entries.Select(e => new + club_stats = winnerAgg.Entries.Select(e => new { - country = e.Name, + club = e.Name, prediction_count = e.PredictionCount, percentage = e.Percentage }) diff --git a/backend/BanterApp.Api/Integrations/Ai/AiOptions.cs b/backend/BanterApp.Api/Integrations/Ai/AiOptions.cs index f034eb4..71c95ae 100644 --- a/backend/BanterApp.Api/Integrations/Ai/AiOptions.cs +++ b/backend/BanterApp.Api/Integrations/Ai/AiOptions.cs @@ -40,12 +40,14 @@ public sealed class AiOptions /// System prompt for rewriting RSS/pundit/match feed cards into banter voice. public string FeedBanterSystemPrompt { get; set; } = - "You rewrite football news and pundit takes for a Gen Z banter feed. Reply ONLY with JSON: " + + "You rewrite Premier League news and pundit takes for a Gen Z banter feed. Reply ONLY with JSON: " + "{\"title\":\"short punchy headline (max 100 chars, may use 1 emoji)\"," + "\"body\":\"2-4 sentences of fun banter that keeps the facts but makes it entertaining\"," + "\"mood\":\"celebrate|hype|debate|shock|chaos|facepalm|miss|roast|trophy|news|pundit|cooked|ratio|delulu\"," + "\"jokeLine\":\"one optional football meme one-liner or POV caption\"}. " + - "Keep real pundit names and predictions accurate. PG-rated. Never encourage gambling."; + "Keep real pundit names and predictions accurate. Vary wording — do not reuse the same joke. " + + "Do not mention World Cup, Brazil, or national-team tournament frames unless the source is explicitly about that. " + + "PG-rated. Never encourage gambling."; /// Max AI generations per anonymous session (registered users unlimited). public int AnonymousGenerationLimit { get; set; } = 3; @@ -67,17 +69,18 @@ public sealed class AiOptions public string FeedVisualSystemPrompt { get; set; } = "You pick the animated reaction GIF for a football banter app feed card. Reply ONLY with JSON: " + "{\"format\":\"gif\",\"mood\":\"celebrate|hype|debate|shock|chaos|facepalm|miss|roast|trophy|news|pundit|cooked|ratio|delulu\"," + - "\"gifQuery\":\"4-8 word Giphy search phrase for an animated sports reaction GIF — NOT an emoji or mood word alone. " + - "Examples: 'ronaldo siuu celebration stadium', 'football fan shocked disbelief', 'roy keane angry pundit desk', 'messi goal celebration gif'. " + - "Include emotion + football/sport context. PG-rated.\"}. " + + "\"gifQuery\":\"4-8 word Giphy search phrase for an animated Premier League reaction GIF — NOT an emoji or mood word alone. " + + "Examples: 'salah celebration anfield', 'football fan shocked disbelief', 'roy keane angry pundit desk', 'haaland goal celebration gif'. " + + "Include emotion + Premier League/club context. Do not search World Cup or national-team tropes unless the source is about that. PG-rated.\"}. " + "Always set format to gif, mood, and a concrete gifQuery."; /// System prompt for structured pundit opinion extraction from articles/transcripts. public string PunditExtractionSystemPrompt { get; set; } = - "You extract structured football pundit opinions and predictions from source text. Reply ONLY with valid JSON. " + + "You extract structured Premier League pundit opinions and predictions from source text. Reply ONLY with valid JSON. " + "Do not invent quotes. Only set is_direct_quote true when the quote appears verbatim in the source text. " + "If the pundit name is unclear, use name Unknown and needs_human_review true. " + - "Preserve source_url, source_name, and source_title from the input. Never fabricate attribution."; + "Preserve source_url, source_name, and source_title from the input. Never fabricate attribution. " + + "If the source is only about the World Cup or another competition, return pundits as an empty array."; public int PunditExtractionMaxTokens { get; set; } = 4096; diff --git a/backend/BanterApp.Api/Integrations/Ai/OpenAiContentGenerator.cs b/backend/BanterApp.Api/Integrations/Ai/OpenAiContentGenerator.cs index 601b569..875240c 100644 --- a/backend/BanterApp.Api/Integrations/Ai/OpenAiContentGenerator.cs +++ b/backend/BanterApp.Api/Integrations/Ai/OpenAiContentGenerator.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +using BanterApp.Api.Data; using BanterApp.Api.Features.Ai; using BanterApp.Api.Features.Pundits; using BanterApp.Api.Integrations.FootballBanter; @@ -19,16 +20,19 @@ public sealed class OpenAiContentGenerator : IContentGenerator { private readonly HttpClient _httpClient; private readonly AiOptions _options; + private readonly IPromptCatalog _prompts; private readonly ILogger _logger; private readonly ConcurrentDictionary _generationCounts = new(); public OpenAiContentGenerator( HttpClient httpClient, IOptions options, + IPromptCatalog prompts, ILogger logger) { _httpClient = httpClient; _options = options.Value; + _prompts = prompts; _logger = logger; } @@ -149,7 +153,10 @@ public async Task GenerateNewsReactionAsync( ? $"Headline: {headline}\nSummary: {summary}" : $"Category: {category}\nHeadline: {headline}\nSummary: {summary}"; - return await CompleteChatAsync(_options.NewsReactionSystemPrompt, userPrompt, cancellationToken); + return await CompleteChatAsync( + await _prompts.ResolveAsync(PromptKeys.NewsReaction, cancellationToken), + userPrompt, + cancellationToken); } public async Task GenerateReactionImageUrlAsync( @@ -164,7 +171,7 @@ public async Task GenerateNewsReactionAsync( } var scenePrompt = - $"{_options.MemeImagePrompt}\nSubject: {headline}\nBanter: {reactionText}"; + $"{await _prompts.ResolveAsync(PromptKeys.MemeImage, cancellationToken)}\nSubject: {headline}\nBanter: {reactionText}"; if (!string.IsNullOrWhiteSpace(category)) { scenePrompt += $"\nContext: {category}"; @@ -185,14 +192,15 @@ public async Task SuggestFeedVisualAsync( } var userPrompt = + $"Focus: {CompetitionFocus.DisplayName}\n" + $"Headline: {headline}\nReaction: {reactionText}\n" + (string.IsNullOrWhiteSpace(category) ? "" : $"Category: {category}\n") + - "Pick the best visual for a football banter feed card."; + "Pick a Premier League reaction GIF. Vary the search phrase."; try { var json = await CompleteChatAsync( - _options.FeedVisualSystemPrompt, + await _prompts.ResolveAsync(PromptKeys.FeedVisual, cancellationToken), userPrompt, cancellationToken, responseFormatJson: true); @@ -219,6 +227,9 @@ public async Task GenerateFeedBanterCardAsync( } var userPrompt = + $"Focus: {CompetitionFocus.DisplayName}\n" + + $"Rewrite nonce: {DateTime.UtcNow:yyyy-MM-dd}\n" + + $"Vary this rewrite; do not reuse World Cup or national-team tournament framing.\n" + $"Category: {category ?? "news"}\n" + (string.IsNullOrWhiteSpace(author) ? "" : $"Pundit/author: {author}\n") + $"Headline: {headline}\nSummary: {summary}"; @@ -226,7 +237,7 @@ public async Task GenerateFeedBanterCardAsync( try { var json = await CompleteChatAsync( - _options.FeedBanterSystemPrompt, + await _prompts.ResolveAsync(PromptKeys.FeedBanter, cancellationToken), userPrompt, cancellationToken, responseFormatJson: true); @@ -501,7 +512,7 @@ public async Task GenerateFootballBanterJsonAsync( var userPrompt = BuildFootballBanterUserPrompt(input, banterIntensity); return await CompleteChatAsync( - systemPrompt, + CompetitionFocus.ApplyToSystemPrompt(systemPrompt), userPrompt, cancellationToken, responseFormatJson: true, @@ -526,14 +537,18 @@ private static string BuildFootballBanterUserPrompt(FootballBanterSourceInput in ["statement_type"] = input.StatementType is null ? null : FootballBanterOutputParser.ToJsonString(input.StatementType.Value), - ["banter_intensity"] = banterIntensity + ["banter_intensity"] = banterIntensity, + ["competition"] = CompetitionFocus.DisplayName, + ["freshness"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), + ["instruction"] = CompetitionFocus.PromptDirective + + " Vary phrasing each time. Do not invent quotes." }; if (!string.IsNullOrWhiteSpace(input.ReferenceContextJson)) { payload["reference_context"] = input.ReferenceContextJson; - payload["instruction"] = - "Only cite statistics from reference_context; do not invent numbers or player/country stats."; + payload["stats_instruction"] = + "Only cite statistics from reference_context; do not invent numbers or player/club stats."; } return JsonSerializer.Serialize(payload, FootballBanterJson.OutputOptions); diff --git a/backend/BanterApp.Api/Integrations/Ai/PromptCatalog.cs b/backend/BanterApp.Api/Integrations/Ai/PromptCatalog.cs new file mode 100644 index 0000000..6eb7df9 --- /dev/null +++ b/backend/BanterApp.Api/Integrations/Ai/PromptCatalog.cs @@ -0,0 +1,207 @@ +using BanterApp.Api.Data; +using BanterApp.Api.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace BanterApp.Api.Integrations.Ai; + +public sealed record PromptDefinition( + string Key, + string Label, + string Description, + bool ApplyCompetitionFocus); + +public static class PromptKeys +{ + public const string NewsReaction = "NewsReactionSystemPrompt"; + public const string FeedBanter = "FeedBanterSystemPrompt"; + public const string MemeImage = "MemeImagePrompt"; + public const string FeedVisual = "FeedVisualSystemPrompt"; + public const string PunditExtraction = "PunditExtractionSystemPrompt"; + + public static readonly IReadOnlyList All = + [ + new( + NewsReaction, + "News reaction", + "Voice for short feed reactions to headlines.", + ApplyCompetitionFocus: true), + new( + FeedBanter, + "Feed banter", + "JSON rewrite of news and pundit takes for the timeline.", + ApplyCompetitionFocus: true), + new( + MemeImage, + "Meme image", + "Prefix for DALL-E stills. Not a chat system prompt.", + ApplyCompetitionFocus: false), + new( + FeedVisual, + "Feed GIF picker", + "JSON picker for Giphy search phrases on feed cards.", + ApplyCompetitionFocus: true), + new( + PunditExtraction, + "Pundit extraction", + "Structured extraction of real pundit opinions. Do not loosen quote rules.", + ApplyCompetitionFocus: true) + ]; + + public static bool TryGet(string? key, out PromptDefinition definition) + { + definition = All.FirstOrDefault(d => + string.Equals(d.Key, key, StringComparison.OrdinalIgnoreCase))!; + return definition is not null; + } +} + +public interface IPromptCatalog +{ + Task ResolveAsync(string key, CancellationToken cancellationToken = default); + + Task> ListAsync(CancellationToken cancellationToken = default); + + Task SaveAsync( + string key, + string? body, + Guid? updatedByUserId, + CancellationToken cancellationToken = default); +} + +public sealed record PromptCatalogEntry( + string Key, + string Label, + string Description, + string Body, + string DefaultBody, + bool IsOverride, + bool ApplyCompetitionFocus, + DateTimeOffset? UpdatedAt); + +public sealed class PromptCatalog( + AppDbContext db, + IOptions options, + IMemoryCache cache) : IPromptCatalog +{ + public const string CacheKeyPrefix = "prompt-override:"; + public static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); + + public async Task ResolveAsync(string key, CancellationToken cancellationToken = default) + { + if (!PromptKeys.TryGet(key, out var definition)) + { + throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown prompt key."); + } + + var fallback = DefaultBody(definition.Key); + var overrideBody = await ReadOverrideAsync(definition.Key, cancellationToken); + var body = string.IsNullOrWhiteSpace(overrideBody) ? fallback : overrideBody; + return definition.ApplyCompetitionFocus + ? CompetitionFocus.ApplyToSystemPrompt(body) + : body; + } + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var rows = await db.PromptOverrides.AsNoTracking().ToListAsync(cancellationToken); + var byKey = rows.ToDictionary(r => r.Key, StringComparer.OrdinalIgnoreCase); + + return PromptKeys.All.Select(definition => + { + byKey.TryGetValue(definition.Key, out var row); + var defaultBody = DefaultBody(definition.Key); + var isOverride = row is not null && !string.IsNullOrWhiteSpace(row.Body); + return new PromptCatalogEntry( + definition.Key, + definition.Label, + definition.Description, + isOverride ? row!.Body : defaultBody, + defaultBody, + isOverride, + definition.ApplyCompetitionFocus, + row?.UpdatedAt); + }).ToList(); + } + + public async Task SaveAsync( + string key, + string? body, + Guid? updatedByUserId, + CancellationToken cancellationToken = default) + { + if (!PromptKeys.TryGet(key, out var definition)) + { + throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown prompt key."); + } + + var trimmed = body?.Trim(); + var existing = await db.PromptOverrides.FirstOrDefaultAsync( + p => p.Key == definition.Key, + cancellationToken); + + if (string.IsNullOrWhiteSpace(trimmed)) + { + if (existing is not null) + { + db.PromptOverrides.Remove(existing); + await db.SaveChangesAsync(cancellationToken); + } + } + else + { + if (trimmed.Length > PromptOverrideLimits.Body) + { + throw new ArgumentException( + $"Prompt body exceeds {PromptOverrideLimits.Body} characters.", + nameof(body)); + } + + var now = DateTimeOffset.UtcNow; + if (existing is null) + { + db.PromptOverrides.Add(new PromptOverride + { + Key = definition.Key, + Body = trimmed, + UpdatedAt = now, + UpdatedByUserId = updatedByUserId + }); + } + else + { + existing.Body = trimmed; + existing.UpdatedAt = now; + existing.UpdatedByUserId = updatedByUserId; + } + + await db.SaveChangesAsync(cancellationToken); + } + + cache.Remove(CacheKeyPrefix + definition.Key); + var list = await ListAsync(cancellationToken); + return list.Single(e => e.Key == definition.Key); + } + + private async Task ReadOverrideAsync(string key, CancellationToken cancellationToken) + { + return await cache.GetOrCreateAsync(CacheKeyPrefix + key, async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + var row = await db.PromptOverrides.AsNoTracking() + .FirstOrDefaultAsync(p => p.Key == key, cancellationToken); + return row?.Body ?? string.Empty; + }); + } + + private string DefaultBody(string key) => key switch + { + PromptKeys.NewsReaction => options.Value.NewsReactionSystemPrompt, + PromptKeys.FeedBanter => options.Value.FeedBanterSystemPrompt, + PromptKeys.MemeImage => options.Value.MemeImagePrompt, + PromptKeys.FeedVisual => options.Value.FeedVisualSystemPrompt, + PromptKeys.PunditExtraction => options.Value.PunditExtractionSystemPrompt, + _ => throw new ArgumentOutOfRangeException(nameof(key), key, "Unknown prompt key.") + }; +} diff --git a/backend/BanterApp.Api/Integrations/Ai/StubContentGenerator.cs b/backend/BanterApp.Api/Integrations/Ai/StubContentGenerator.cs index 973d2a6..c7ead68 100644 --- a/backend/BanterApp.Api/Integrations/Ai/StubContentGenerator.cs +++ b/backend/BanterApp.Api/Integrations/Ai/StubContentGenerator.cs @@ -276,6 +276,11 @@ private async Task EnsureCanGenerateAsync( "That face you make when the transfer rumour was actually true.", "Nobody: … Football Twitter: allow me to introduce myself.", "VAR review on my ability to cope with this headline.", + "Stoppage time and my group chat both still going.", + "Big six energy until the table says otherwise.", + "Clean sheet? In this economy?", + "That's a sitter. That's also my weekend.", + "Monday Night Football voice: 'they've got to do better'.", ]; public Task GenerateNewsReactionAsync( @@ -355,7 +360,7 @@ public Task GenerateFeedBanterCardAsync( var jokeLine = PickTemplate(FootballJokeLines, headline); var moods = new[] { "celebrate", "debate", "shock", "facepalm", "hype", "pundit", "cooked", "ratio", "delulu" }; - var mood = moods[Math.Abs($"{headline}|{category}".GetHashCode()) % moods.Length]; + var mood = moods[Math.Abs(HashCode.Combine($"{headline}|{category}".GetHashCode(StringComparison.Ordinal), DateTime.UtcNow.DayOfYear)) % moods.Length]; return Task.FromResult(new FeedBanterCard(title, body, mood, jokeLine)); } @@ -393,7 +398,8 @@ private static string ResolveUserKey(string? userId, bool isAnonymous) private static string PickTemplate(string[] templates, params string[] seedParts) { var seed = string.Join('|', seedParts); - var index = Math.Abs(seed.GetHashCode()) % templates.Length; + var index = Math.Abs(HashCode.Combine(seed.GetHashCode(StringComparison.Ordinal), DateTime.UtcNow.DayOfYear)) % + templates.Length; return templates[index]; } diff --git a/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterConfigLoader.cs b/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterConfigLoader.cs index 7df32c2..eadf8b9 100644 --- a/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterConfigLoader.cs +++ b/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterConfigLoader.cs @@ -1,3 +1,5 @@ +using BanterApp.Api.Data; + namespace BanterApp.Api.Integrations.FootballBanter; public sealed class FootballBanterConfigLoadResult @@ -77,6 +79,6 @@ public static string LoadSystemPromptFromContentRoot(string contentRootPath) var prompt = File.ReadAllText(promptPath).Trim(); return string.IsNullOrWhiteSpace(prompt) ? FootballBanterDefaults.EmbeddedSystemPromptFallback - : prompt; + : CompetitionFocus.ApplyToSystemPrompt(prompt); } } diff --git a/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterEngine.cs b/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterEngine.cs index 4de6903..e7dee51 100644 --- a/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterEngine.cs +++ b/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterEngine.cs @@ -14,15 +14,18 @@ public sealed class FootballBanterEngine : IFootballBanterEngine { private readonly IFootballBanterConfigProvider _config; private readonly IContentGenerator _contentGenerator; + private readonly IPromptCatalog _prompts; private readonly ILogger _logger; public FootballBanterEngine( IFootballBanterConfigProvider config, IContentGenerator contentGenerator, + IPromptCatalog prompts, ILogger logger) { _config = config; _contentGenerator = contentGenerator; + _prompts = prompts; _logger = logger; } @@ -41,7 +44,7 @@ public async Task GenerateAsync( { var json = await _contentGenerator.GenerateFootballBanterJsonAsync( input, - _config.SystemPrompt, + await _prompts.ResolveAsync(PromptKeys.FeedBanter, cancellationToken), _config.Config.OpenAi, _config.Config.Banter.DefaultIntensity, cancellationToken); diff --git a/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterJson.cs b/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterJson.cs index 58bc415..3a1b66b 100644 --- a/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterJson.cs +++ b/backend/BanterApp.Api/Integrations/FootballBanter/FootballBanterJson.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using BanterApp.Api.Data; namespace BanterApp.Api.Integrations.FootballBanter; @@ -23,11 +24,12 @@ internal static class FootballBanterJson internal static class FootballBanterDefaults { - public const string EmbeddedSystemPromptFallback = - "You are the Football Banter Engine. Transform grounded football source content into Gen Z banter JSON. " + - "Never invent quotes or predictions. Always include source_name and source_url. " + - "Return JSON only with headline, banter_summary, meme_reactions, gif_suggestions, fan_reactions, " + - "confidence, source_name, source_url, pundit_name, prediction, statement_type, needs_human_review."; + public static string EmbeddedSystemPromptFallback => + CompetitionFocus.ApplyToSystemPrompt( + "You are the Football Banter Engine. Transform grounded football source content into Gen Z banter JSON. " + + "Never invent quotes or predictions. Always include source_name and source_url. " + + "Return JSON only with headline, banter_summary, meme_reactions, gif_suggestions, fan_reactions, " + + "confidence, source_name, source_url, pundit_name, prediction, statement_type, needs_human_review."); public const double ReviewConfidenceThreshold = 0.7; } diff --git a/backend/BanterApp.Api/Integrations/Media/FootballGifQuery.cs b/backend/BanterApp.Api/Integrations/Media/FootballGifQuery.cs index 602a57e..6b38845 100644 --- a/backend/BanterApp.Api/Integrations/Media/FootballGifQuery.cs +++ b/backend/BanterApp.Api/Integrations/Media/FootballGifQuery.cs @@ -1,3 +1,5 @@ +using BanterApp.Api.Data; + namespace BanterApp.Api.Integrations.Media; /// @@ -20,13 +22,41 @@ public static class FootballGifQuery "shocked football fan", "football roast meme", "trophy lift football", + "arsenal celebration", + "liverpool anfield celebration", + "manchester city goal", + "saka celebration", + "salah celebration", + "haaland celebration", + "gary neville pundit", + "sky sports super sunday", + "monday night football", + "premier league derby", + "last minute winner premier league", + "var controversy premier league", + "red card premier league", + "football fan facepalm", + "pundit shocked reaction", + "premier league title race", + "big six premier league", + "clean sheet celebration", + "hat trick celebration", + "own goal football", + "penalty miss football", + "crowd eruption stadium", + "roy keane angry pundit", + "mourinho press conference", + "premier league table", + "matchday chaos football", + "late drama premier league", + "tottenham spurs celebration" ]; private static readonly string[] FootballTokens = [ "football", "soccer", "premier", "epl", "goal", "offside", "referee", "var", "celebration", "stadium", "matchday", "kickoff", "hat-trick", "hat trick", - "red card", "yellow card", "transfer", "derby", "world cup", "champions league", + "red card", "yellow card", "transfer", "derby", "champions league", "ucl", "europa", "arsenal", "chelsea", "liverpool", "united", "spurs", "tottenham", "newcastle", "brighton", "villa", "hammers", "palace", "everton", "wolves", "fulham", "brentford", "bournemouth", "forest", "burnley", "leeds", @@ -35,7 +65,7 @@ public static class FootballGifQuery public static bool LooksFootball(string? phrase) { - if (string.IsNullOrWhiteSpace(phrase)) + if (string.IsNullOrWhiteSpace(phrase) || CompetitionFocus.LooksLikeOffFocus(phrase)) { return false; } diff --git a/backend/BanterApp.Api/Integrations/Media/GifLibraryService.cs b/backend/BanterApp.Api/Integrations/Media/GifLibraryService.cs index 15235ae..123b1ff 100644 --- a/backend/BanterApp.Api/Integrations/Media/GifLibraryService.cs +++ b/backend/BanterApp.Api/Integrations/Media/GifLibraryService.cs @@ -69,6 +69,78 @@ public sealed class GifLibraryService "Pundit-desk energy. Safe pick, studio consensus, playing it down the middle.", "pundit", "pundit,news,debate"), + new( + "/reactions/delulu-vision.svg", + "Delulu vision", + "Unshakable belief in a wild Premier League take. Delulu, but committed.", + "delulu", + "delulu,hype,chaos"), + new( + "/reactions/last-gasp.svg", + "Last gasp", + "Stoppage-time winner energy. The group chat is still catching up.", + "hype", + "hype,celebrate,miss,chaos"), + new( + "/reactions/var-check.svg", + "VAR check", + "The wait. The still. The groan. Premier League VAR theatre.", + "debate", + "debate,shock,pundit"), + new( + "/reactions/red-carded.svg", + "Sent off", + "Straight red. The take, the tackle, or the night is over.", + "shock", + "shock,chaos,roast"), + new( + "/reactions/ratioed.svg", + "Ratioed", + "The replies cooked the original take. Timeline 1, pundit 0.", + "ratio", + "ratio,roast,cooked"), + new( + "/reactions/cooked-take.svg", + "Cooked take", + "That prediction did not survive kickoff. Fully cooked.", + "cooked", + "cooked,facepalm,roast"), + new( + "/reactions/derby-day.svg", + "Derby day", + "Local-rival energy. Nobody is playing it safe this weekend.", + "hype", + "hype,debate,chaos"), + new( + "/reactions/table-talk.svg", + "Table talk", + "Standings, run-ins, and who is actually in the title race.", + "news", + "news,pundit,debate"), + new( + "/reactions/missed-sitter.svg", + "Sitter", + "Open net, open goal, open embarrassment. Facepalm football.", + "facepalm", + "facepalm,miss,roast"), + new( + "/reactions/big-six.svg", + "Big six", + "The usual suspects arguing over who is actually elite this season.", + "debate", + "debate,pundit,hype"), + new( + "/reactions/clean-sheet.svg", + "Clean sheet", + "Defensive masterclass energy. Keepers eating tonight.", + "celebrate", + "celebrate,trophy,news"), + new( + "/reactions/late-drama.svg", + "Late drama", + "Added time chaos. The script writers are working overtime.", + "chaos", + "chaos,shock,hype"), ]; private readonly AppDbContext _db; diff --git a/backend/BanterApp.Api/Integrations/Media/YouTubeOptions.cs b/backend/BanterApp.Api/Integrations/Media/YouTubeOptions.cs index 5867893..4b24497 100644 --- a/backend/BanterApp.Api/Integrations/Media/YouTubeOptions.cs +++ b/backend/BanterApp.Api/Integrations/Media/YouTubeOptions.cs @@ -1,3 +1,5 @@ +using BanterApp.Api.Data; + namespace BanterApp.Api.Integrations.Media; public sealed class YouTubeOptions @@ -8,10 +10,5 @@ public sealed class YouTubeOptions public string? ApiKey { get; set; } - public string[] DefaultSearchTerms { get; set; } = - [ - "Premier League predictions", - "Premier League preview", - "Premier League score prediction" - ]; + public string[] DefaultSearchTerms { get; set; } = [.. CompetitionFocus.YouTubeSearchQueries]; } diff --git a/backend/BanterApp.Api/Integrations/News/NewsIngestJob.cs b/backend/BanterApp.Api/Integrations/News/NewsIngestJob.cs index dc55723..69fe67a 100644 --- a/backend/BanterApp.Api/Integrations/News/NewsIngestJob.cs +++ b/backend/BanterApp.Api/Integrations/News/NewsIngestJob.cs @@ -70,6 +70,11 @@ public async Task IngestAsync(CancellationToken cancellationToken) foreach (var article in articles) { + if (CompetitionFocus.LooksLikeOffFocus(article.Title, article.SourceUrl, article.Summary)) + { + continue; + } + try { var (a, u) = await UpsertArticleAsync(article, cancellationToken); diff --git a/backend/BanterApp.Api/Integrations/News/RssNewsProvider.cs b/backend/BanterApp.Api/Integrations/News/RssNewsProvider.cs index 0687b76..f98edde 100644 --- a/backend/BanterApp.Api/Integrations/News/RssNewsProvider.cs +++ b/backend/BanterApp.Api/Integrations/News/RssNewsProvider.cs @@ -1,4 +1,5 @@ using BanterApp.Api.Common; +using BanterApp.Api.Data; using BanterApp.Api.Integrations.Media; using BanterApp.Api.Integrations.Media.Dtos; using BanterApp.Api.Integrations.Rss; @@ -60,6 +61,11 @@ public async Task> GetLatestArticlesAsync( foreach (var item in fetched.Items) { + if (CompetitionFocus.LooksLikeOffFocus(item.Title, item.SourceUrl, item.Description, item.FullText)) + { + continue; + } + articles.Add(MapItem(item, feed.Url, feed.Name)); } } diff --git a/backend/BanterApp.Api/Integrations/Pundits/OpenAiPunditOpinionExtractor.cs b/backend/BanterApp.Api/Integrations/Pundits/OpenAiPunditOpinionExtractor.cs index 6e146dd..8f1bdcb 100644 --- a/backend/BanterApp.Api/Integrations/Pundits/OpenAiPunditOpinionExtractor.cs +++ b/backend/BanterApp.Api/Integrations/Pundits/OpenAiPunditOpinionExtractor.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using System.Text.Json; using BanterApp.Api.Common; +using BanterApp.Api.Data; using BanterApp.Api.Features.Matches; using BanterApp.Api.Integrations.Ai; using BanterApp.Api.Integrations.Common; @@ -15,17 +16,20 @@ public sealed class OpenAiPunditOpinionExtractor : IPunditOpinionExtractor { private readonly HttpClient _httpClient; private readonly AiOptions _options; + private readonly IPromptCatalog _prompts; private readonly MatchResolutionService _matchResolution; private readonly ILogger _logger; public OpenAiPunditOpinionExtractor( HttpClient httpClient, IOptions options, + IPromptCatalog prompts, MatchResolutionService matchResolution, ILogger logger) { _httpClient = httpClient; _options = options.Value; + _prompts = prompts; _matchResolution = matchResolution; _logger = logger; } @@ -49,7 +53,9 @@ public OpenAiPunditOpinionExtractor( var fixtureCatalog = await _matchResolution.BuildFixtureCatalogJsonAsync(cancellationToken: cancellationToken); var userPrompt = PromptGuard.UntrustedSourceInstruction + "\n\n" + - "Extract pundit opinions and predictions from this source.\n\n" + + CompetitionFocus.PromptDirective + "\n\n" + + "Extract pundit opinions and predictions from this source.\n" + + "If the source is only about the World Cup or another competition, return pundits as an empty array.\n\n" + $"source_type: {sourceType}\n" + $"source_name: {sourceName}\n" + $"source_url: {sourceUrl}\n" + @@ -69,7 +75,7 @@ public OpenAiPunditOpinionExtractor( try { var json = await CompleteChatAsync( - _options.PunditExtractionSystemPrompt, + await _prompts.ResolveAsync(PromptKeys.PunditExtraction, cancellationToken), userPrompt, sourceUrl, cancellationToken); diff --git a/backend/BanterApp.Api/Integrations/Pundits/PunditExtractionJob.cs b/backend/BanterApp.Api/Integrations/Pundits/PunditExtractionJob.cs index 4d075b1..909b123 100644 --- a/backend/BanterApp.Api/Integrations/Pundits/PunditExtractionJob.cs +++ b/backend/BanterApp.Api/Integrations/Pundits/PunditExtractionJob.cs @@ -90,6 +90,13 @@ public async Task ExtractAsync(CancellationToken cancellationToken) } var sourceText = item.RawText; + if (CompetitionFocus.LooksLikeOffFocus(item.Title, item.SourceUrl, item.Description, sourceText)) + { + item.ProcessingStatus = MediaItemProcessingStatus.Skipped; + item.ProcessingError = $"Off-focus for {CompetitionFocus.DisplayName}."; + continue; + } + if (!SourceTextQuality.IsUsable(sourceText, _options.MinSourceTextLength) || SourceTextQuality.IsTitleDescriptionFallback(item.Title, item.Description, sourceText)) { diff --git a/backend/BanterApp.Api/Integrations/Pundits/PunditIngestOptions.cs b/backend/BanterApp.Api/Integrations/Pundits/PunditIngestOptions.cs index 8ea3a77..2b3340a 100644 --- a/backend/BanterApp.Api/Integrations/Pundits/PunditIngestOptions.cs +++ b/backend/BanterApp.Api/Integrations/Pundits/PunditIngestOptions.cs @@ -1,3 +1,5 @@ +using BanterApp.Api.Data; + namespace BanterApp.Api.Integrations.Pundits; public sealed class PunditIngestOptions @@ -6,7 +8,7 @@ public sealed class PunditIngestOptions public bool Enabled { get; set; } = true; - public string[] YouTubeSearchQueries { get; set; } = []; + public string[] YouTubeSearchQueries { get; set; } = [.. CompetitionFocus.YouTubeSearchQueries]; public string[] RssFeedUrls { get; set; } = []; diff --git a/backend/BanterApp.Api/Integrations/Pundits/PunditMediaItemService.cs b/backend/BanterApp.Api/Integrations/Pundits/PunditMediaItemService.cs index 37d6600..0459484 100644 --- a/backend/BanterApp.Api/Integrations/Pundits/PunditMediaItemService.cs +++ b/backend/BanterApp.Api/Integrations/Pundits/PunditMediaItemService.cs @@ -76,6 +76,11 @@ public async Task EnsureSourceAsync( var hash = ContentHashHelper.Compute(externalId, item.SourceUrl, item.Title); + if (CompetitionFocus.LooksLikeOffFocus(item.Title, item.SourceUrl, item.Description, item.FullText)) + { + return (0, 0, 1, false); + } + var duplicateHash = _db.MediaItems.Local.Any(x => x.ContentHash == hash && x.MediaSourceId != source.Id) || await _db.MediaItems diff --git a/backend/BanterApp.Api/Integrations/ServiceCollectionExtensions.cs b/backend/BanterApp.Api/Integrations/ServiceCollectionExtensions.cs index aaf015f..4edd9f9 100644 --- a/backend/BanterApp.Api/Integrations/ServiceCollectionExtensions.cs +++ b/backend/BanterApp.Api/Integrations/ServiceCollectionExtensions.cs @@ -57,6 +57,8 @@ public static IServiceCollection AddBanterIntegrations( var logger = sp.GetRequiredService>(); return FootballBanterConfigProvider.Create(env.ContentRootPath, logger); }); + services.AddMemoryCache(); + services.AddScoped(); services.AddScoped(); services.AddSingleton, FootballBanterPunditIngestPostConfigurer>(); services.AddSingleton, FootballBanterYouTubePostConfigurer>(); diff --git a/backend/BanterApp.Api/appsettings.Development.json.example b/backend/BanterApp.Api/appsettings.Development.json.example index 4636504..3ab40c9 100644 --- a/backend/BanterApp.Api/appsettings.Development.json.example +++ b/backend/BanterApp.Api/appsettings.Development.json.example @@ -39,11 +39,9 @@ "BaseUrl": "https://www.googleapis.com/youtube/v3", "ApiKey": "", "DefaultSearchTerms": [ - "Premier League", - "Premier League highlights", - "Premier League predictions", - "Premier League preview", - "Premier League score prediction" + "Premier League 2026/27 predictions pundits", + "Premier League weekend preview predictions", + "Sky Sports Premier League Super Sunday predictions" ] }, "ReactionGif": { @@ -84,12 +82,14 @@ "PunditIngest": { "Enabled": true, "YouTubeSearchQueries": [ - "Premier League predictions pundits", - "football pundits predictions", - "BBC Sport Premier League predictions", - "Sky Sports Premier League predictions", - "ESPN FC predictions", - "Guardian Football Premier League analysis" + "Premier League 2026/27 predictions pundits", + "Premier League weekend preview predictions", + "Sky Sports Premier League Super Sunday predictions", + "Monday Night Football Premier League predictions", + "The Rest Is Football Premier League", + "Stick to Football Premier League predictions", + "Gary Neville Premier League prediction", + "BBC Match of the Day Premier League analysis" ], "MaxItemsPerSource": 25, "ExtractionBatchSize": 5, diff --git a/backend/BanterApp.Api/appsettings.json b/backend/BanterApp.Api/appsettings.json index bf2c0c4..1f010ae 100644 --- a/backend/BanterApp.Api/appsettings.json +++ b/backend/BanterApp.Api/appsettings.json @@ -198,12 +198,14 @@ "PunditIngest": { "Enabled": true, "YouTubeSearchQueries": [ - "Premier League predictions pundits", - "football pundits predictions", - "BBC Sport Premier League predictions", - "Sky Sports Premier League predictions", - "ESPN FC predictions", - "Guardian Football Premier League analysis" + "Premier League 2026/27 predictions pundits", + "Premier League weekend preview predictions", + "Sky Sports Premier League Super Sunday predictions", + "Monday Night Football Premier League predictions", + "The Rest Is Football Premier League", + "Stick to Football Premier League predictions", + "Gary Neville Premier League prediction", + "BBC Match of the Day Premier League analysis" ], "MaxItemsPerSource": 25, "ExtractionBatchSize": 5, @@ -236,11 +238,11 @@ "ImageModel": "dall-e-3", "ImageSize": "1024x1024", "NewsReactionSystemPrompt": "You are BanterBot — a chaotic Gen Z football fan running the group chat on a banter app. React in 2-3 short sentences: spicy, funny, PG-rated, meme-adjacent. Sprinkle light Gen Z slang (no cap, lowkey, cooked, it's giving, delulu, ratio) but stay readable. Drop football jokes and banter — never encourage gambling or hate.", - "FeedBanterSystemPrompt": "You rewrite football news and pundit takes for a Gen Z banter feed. Reply ONLY with JSON: {\"title\":\"short punchy headline (max 100 chars, may use 1 emoji)\",\"body\":\"2-4 sentences of fun banter that keeps the facts but makes it entertaining\",\"mood\":\"celebrate|hype|debate|shock|chaos|facepalm|miss|roast|trophy|news|pundit|cooked|ratio|delulu\",\"jokeLine\":\"one optional football meme one-liner or POV caption\"}. Keep real pundit names and predictions accurate. PG-rated. Never encourage gambling.", + "FeedBanterSystemPrompt": "You rewrite Premier League news and pundit takes for a Gen Z banter feed. Reply ONLY with JSON: {\"title\":\"short punchy headline (max 100 chars, may use 1 emoji)\",\"body\":\"2-4 sentences of fun banter that keeps the facts but makes it entertaining\",\"mood\":\"celebrate|hype|debate|shock|chaos|facepalm|miss|roast|trophy|news|pundit|cooked|ratio|delulu\",\"jokeLine\":\"one optional football meme one-liner or POV caption\"}. Keep real pundit names and predictions accurate. Vary wording. Do not mention World Cup or national-team tournament frames unless the source is explicitly about that. PG-rated. Never encourage gambling.", "MemeImagePrompt": "Funny football banter meme illustration, bold expressive cartoon style, single frozen frame like a viral sports GIF, PG-rated, no text in image.", "PunditExtractionMaxTokens": 4096, "PunditExtractionTemperature": 0.2, - "PunditExtractionSystemPrompt": "You extract structured football pundit opinions and predictions from source text. Reply ONLY with valid JSON matching the requested schema. Do not invent quotes. Only set is_direct_quote true when the quote appears verbatim in the source text. If the pundit name is unclear, use name Unknown and needs_human_review true. Preserve source_url, source_name, and source_title from the input. Never fabricate attribution." + "PunditExtractionSystemPrompt": "You extract structured Premier League pundit opinions and predictions from source text. Reply ONLY with valid JSON matching the requested schema. Do not invent quotes. Only set is_direct_quote true when the quote appears verbatim in the source text. If the pundit name is unclear, use name Unknown and needs_human_review true. Preserve source_url, source_name, and source_title from the input. Never fabricate attribution. If the source is only about the World Cup or another competition, return pundits as an empty array." }, "Admin": { "AllowedEmails": [], diff --git a/backend/BanterApp.Api/config/football-banter.config.json b/backend/BanterApp.Api/config/football-banter.config.json index dbb7432..affeb3b 100644 --- a/backend/BanterApp.Api/config/football-banter.config.json +++ b/backend/BanterApp.Api/config/football-banter.config.json @@ -8,11 +8,13 @@ "api_base_url": "https://www.googleapis.com/youtube/v3", "search_terms": [ "Premier League 2026/27 predictions pundits", - "football pundits predictions", - "BBC Sport Premier League predictions", - "Sky Sports Premier League predictions", - "ESPN FC predictions", - "Guardian Football Premier League analysis" + "Premier League weekend preview predictions", + "Sky Sports Premier League Super Sunday predictions", + "Monday Night Football Premier League predictions", + "The Rest Is Football Premier League", + "Stick to Football Premier League predictions", + "Gary Neville Premier League prediction", + "BBC Match of the Day Premier League analysis" ], "max_results_per_search": 25, "sync_interval_minutes": 180, diff --git a/backend/BanterApp.Api/prompts/football-banter-system-prompt.md b/backend/BanterApp.Api/prompts/football-banter-system-prompt.md index b38f79f..cb93f75 100644 --- a/backend/BanterApp.Api/prompts/football-banter-system-prompt.md +++ b/backend/BanterApp.Api/prompts/football-banter-system-prompt.md @@ -2,7 +2,9 @@ You are the Football Banter Engine for a football fan platform. -Your job is to transform grounded football news, pundit opinions, predictions, RSS articles, YouTube metadata, and transcripts into funny, shareable football banter. +The product currently covers Premier League 2026/27 only. Search, extract, and write about that competition. Ignore World Cup, FIFA tournaments, international friendlies, and other leagues unless they directly affect a Premier League club this season. Do not invent World Cup predictions, national-team frames, or Brazil/England tournament tropes. + +Your job is to transform grounded football news, pundit opinions, predictions, RSS articles, YouTube metadata, and transcripts into funny, shareable football banter. Vary wording so cards do not reuse the same joke. You are not a formal journalist. You write like football Twitter/X, a football meme page, a TikTok comments section, and a football group chat combined. @@ -57,16 +59,16 @@ Return JSON only: ## Style examples Instead of: -"Gary Neville believes England can reach the semi-finals." +"Gary Neville believes Arsenal can win the title." Write: -"Gary Neville has entered his annual 'football is coming home' phase 😂🏴" +"Gary Neville has entered his annual 'this is Arsenal's year' phase 😂🔴" Instead of: -"Several pundits are backing Brazil." +"Several pundits are backing Manchester City." Write: -"Every pundit backing Brazil like they have tomorrow's lottery numbers 😭🇧🇷" +"Every pundit backing City like they have next week's lottery numbers 😭🩵" ## GIF suggestions @@ -76,8 +78,8 @@ Examples: - "Jose Mourinho smug smile celebration" - "Roy Keane angry pundit desk" - "Thierry Henry disbelief reaction" -- "football crowd eruption goal" -- "Mbappe laughing interview" +- "premier league crowd eruption goal" +- "Salah celebration Anfield" ## Human review diff --git a/frontend/e2e/responsive/helpers.ts b/frontend/e2e/responsive/helpers.ts index 7f27bae..21a2856 100644 --- a/frontend/e2e/responsive/helpers.ts +++ b/frontend/e2e/responsive/helpers.ts @@ -51,4 +51,5 @@ export const adminRoutes = [ "/admin/review", "/admin/stats", "/admin/health", + "/admin/prompts", ] as const; diff --git a/frontend/public/reactions/big-six.svg b/frontend/public/reactions/big-six.svg new file mode 100644 index 0000000..dde6894 --- /dev/null +++ b/frontend/public/reactions/big-six.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 6️⃣ + BIG SIX + + diff --git a/frontend/public/reactions/clean-sheet.svg b/frontend/public/reactions/clean-sheet.svg new file mode 100644 index 0000000..2a2b74e --- /dev/null +++ b/frontend/public/reactions/clean-sheet.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 🧤 + CLEAN SHEET + + diff --git a/frontend/public/reactions/cooked-take.svg b/frontend/public/reactions/cooked-take.svg new file mode 100644 index 0000000..ba7ae29 --- /dev/null +++ b/frontend/public/reactions/cooked-take.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 🍳 + COOKED + + diff --git a/frontend/public/reactions/derby-day.svg b/frontend/public/reactions/derby-day.svg new file mode 100644 index 0000000..f290c18 --- /dev/null +++ b/frontend/public/reactions/derby-day.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + ⚔️ + DERBY DAY + + diff --git a/frontend/public/reactions/last-gasp.svg b/frontend/public/reactions/last-gasp.svg new file mode 100644 index 0000000..cd2e7c6 --- /dev/null +++ b/frontend/public/reactions/last-gasp.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + ⏱️ + LAST GASP + + diff --git a/frontend/public/reactions/late-drama.svg b/frontend/public/reactions/late-drama.svg new file mode 100644 index 0000000..7c16280 --- /dev/null +++ b/frontend/public/reactions/late-drama.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 💥 + LATE DRAMA + + diff --git a/frontend/public/reactions/missed-sitter.svg b/frontend/public/reactions/missed-sitter.svg new file mode 100644 index 0000000..cee6893 --- /dev/null +++ b/frontend/public/reactions/missed-sitter.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 🤦 + SITTER + + diff --git a/frontend/public/reactions/ratioed.svg b/frontend/public/reactions/ratioed.svg new file mode 100644 index 0000000..2242a99 --- /dev/null +++ b/frontend/public/reactions/ratioed.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 📉 + RATIOED + + diff --git a/frontend/public/reactions/red-carded.svg b/frontend/public/reactions/red-carded.svg new file mode 100644 index 0000000..bf84878 --- /dev/null +++ b/frontend/public/reactions/red-carded.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 🟥 + SENT OFF + + diff --git a/frontend/public/reactions/table-talk.svg b/frontend/public/reactions/table-talk.svg new file mode 100644 index 0000000..e355346 --- /dev/null +++ b/frontend/public/reactions/table-talk.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 📊 + TABLE TALK + + diff --git a/frontend/public/reactions/var-check.svg b/frontend/public/reactions/var-check.svg new file mode 100644 index 0000000..f18f1f2 --- /dev/null +++ b/frontend/public/reactions/var-check.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + 📺 + VAR CHECK + + diff --git a/frontend/src/app/admin/prompts/page.tsx b/frontend/src/app/admin/prompts/page.tsx new file mode 100644 index 0000000..b6a09c6 --- /dev/null +++ b/frontend/src/app/admin/prompts/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useAdminToast } from "@/components/admin/AdminToast"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAdminPrompts, useAdminSavePrompt } from "@/hooks/admin/useAdmin"; +import { getApiErrorMessage } from "@/lib/api"; +import type { AdminPrompt } from "@/lib/admin/types"; + +export default function AdminPromptsPage() { + const { data: prompts, isLoading, isError, refetch } = useAdminPrompts(); + const savePrompt = useAdminSavePrompt(); + const { showToast } = useAdminToast(); + const [selectedKey, setSelectedKey] = useState(""); + const [drafts, setDrafts] = useState>({}); + + const selected = useMemo( + () => prompts?.find((p) => p.key === selectedKey) ?? prompts?.[0] ?? null, + [prompts, selectedKey] + ); + const draft = selected ? (drafts[selected.key] ?? selected.body) : ""; + const dirty = selected != null && draft !== selected.body; + + const save = async (body: string | null) => { + if (!selected) { + return; + } + const key = selected.key; + try { + await savePrompt.mutateAsync({ key, body }); + setDrafts((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + showToast(body == null || body.trim() === "" ? "Reverted to shipped default" : "Prompt saved"); + await refetch(); + } catch (e) { + showToast(getApiErrorMessage(e), "error"); + } + }; + + if (isLoading) return ; + + if (isError) { + return ( +
+

Prompts

+

Could not load prompt overrides.

+ +
+ ); + } + + if (!prompts?.length || !selected) { + return ( +
+

Prompts

+

No prompt keys are registered.

+
+ ); + } + + return ( +
+
+

Prompts

+

+ Override AI system prompts in the database. Empty rows keep the shipped default. + SQL edits apply within about a minute without a redeploy. +

+
+ +
+ +

+ {selected.isOverride ? "Database override" : "Shipped default"} +

+
+ +

{selected.description}

+

{selected.key}

+ {selected.applyCompetitionFocus ? ( +

+ Competition focus is still prepended at runtime so World Cup framing cannot sneak back in. +

+ ) : null} + +