Skip to content

Commit 79bd4f5

Browse files
committed
feat: implement meta intelligence
1 parent bdc8e9c commit 79bd4f5

20 files changed

Lines changed: 1670 additions & 2 deletions

app/jobs/sync_match_job.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def perform(match_id, organization_id, region = 'BR')
5757
create_player_match_stats(match, match_data[:participants], organization)
5858

5959
Rails.logger.info("Successfully synced match #{match_id}")
60+
61+
MetaIntelligence::Jobs::UpdateMetaStatsJob.perform_later(organization_id)
6062
end
6163
rescue RiotApiService::NotFoundError => e
6264
Rails.logger.error("Match not found in Riot API: #{match_id} - #{e.message}")

app/models/organization.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ class Organization < ApplicationRecord
4848
# New tier-based associations
4949
has_many :scrims, dependent: :destroy
5050
has_many :competitive_matches, dependent: :destroy
51-
has_many :messages, dependent: :destroy
51+
has_many :messages, dependent: :destroy
52+
has_many :saved_builds, dependent: :destroy
5253

5354
# Validations
5455
validates :name, presence: true, length: { maximum: 255 }

app/models/saved_build.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
# Stores build configurations for champions, either created manually by coaches
4+
# or automatically aggregated from match history by BuildAggregatorService.
5+
#
6+
# Multi-tenant: always scoped by organization_id.
7+
# Performance metrics are calculated asynchronously by UpdateMetaStatsJob.
8+
#
9+
# @example Find best ADC builds for current patch
10+
# org.saved_builds.by_role('adc').by_patch('14.24').ranked_by_win_rate
11+
class SavedBuild < ApplicationRecord
12+
belongs_to :organization
13+
belongs_to :created_by, class_name: 'User', optional: true
14+
15+
DATA_SOURCES = %w[manual aggregated].freeze
16+
ROLES = %w[top jungle mid adc support].freeze
17+
18+
validates :champion, presence: true, length: { maximum: 100 }
19+
validates :role, inclusion: { in: ROLES }, allow_blank: true
20+
validates :data_source, inclusion: { in: DATA_SOURCES }
21+
validates :games_played, numericality: { greater_than_or_equal_to: 0 }
22+
validates :win_rate,
23+
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 },
24+
allow_nil: true
25+
26+
# --- Scopes ---
27+
28+
scope :by_champion, lambda { |champion|
29+
where(champion: champion)
30+
}
31+
32+
scope :by_role, lambda { |role|
33+
where(role: role)
34+
}
35+
36+
scope :by_patch, lambda { |patch|
37+
where(patch_version: patch)
38+
}
39+
40+
scope :aggregated, -> { where(data_source: 'aggregated') }
41+
scope :manual, -> { where(data_source: 'manual') }
42+
scope :public_builds, -> { where(is_public: true) }
43+
44+
scope :ranked_by_win_rate, lambda {
45+
order(win_rate: :desc, games_played: :desc)
46+
}
47+
48+
scope :with_sufficient_sample, lambda {
49+
where('games_played >= ?', MetaIntelligence::Services::BuildAggregatorService::MINIMUM_SAMPLE_SIZE)
50+
}
51+
52+
# --- Predicates ---
53+
54+
# @return [Boolean] true if this build was auto-generated from match data
55+
def aggregated?
56+
data_source == 'aggregated'
57+
end
58+
59+
# @return [Boolean] true if this build was manually created by a coach
60+
def manual?
61+
data_source == 'manual'
62+
end
63+
64+
# @return [String] win rate formatted for display (e.g. "62.5%")
65+
def win_rate_display
66+
"#{win_rate.to_f.round(1)}%"
67+
end
68+
69+
end

app/modules/competitive/controllers/pro_matches_controller.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,89 @@ def sync_from_leaguepedia
223223
}, status: :service_unavailable
224224
end
225225

226+
# GET /api/v1/competitive/pro-matches/diagnose-missing
227+
# Cross-reference Leaguepedia Cargo API with our DB to find missing games.
228+
# Bypasses the ProStaff Scraper — queries Leaguepedia directly.
229+
#
230+
# @param overview_page [String] required — Leaguepedia OverviewPage
231+
# @param our_team [String] required — team name as in Leaguepedia
232+
def diagnose_missing
233+
overview_page = params.require(:overview_page)
234+
our_team = params[:our_team].presence
235+
raise ActionController::ParameterMissing.new(:our_team) if our_team.blank?
236+
237+
service = ::Competitive::Services::LeaguepediaRecoveryService.new(current_organization)
238+
games = service.diagnose_missing(overview_page: overview_page, our_team: our_team)
239+
240+
missing = games.reject { |g| g[:present_in_db] }
241+
present = games.select { |g| g[:present_in_db] }
242+
243+
render json: {
244+
message: "Diagnosis complete for #{our_team}",
245+
data: {
246+
overview_page: overview_page,
247+
our_team: our_team,
248+
total_in_leaguepedia: games.size,
249+
present_in_db: present.size,
250+
missing_count: missing.size,
251+
missing_games: missing,
252+
present_games: present
253+
}
254+
}
255+
rescue ActionController::ParameterMissing => e
256+
render json: { error: { code: 'MISSING_PARAM', message: e.message } },
257+
status: :unprocessable_entity
258+
rescue StandardError => e
259+
Rails.logger.error "[ProMatches#diagnose_missing] #{e.message}"
260+
render json: { error: { code: 'LEAGUEPEDIA_ERROR', message: e.message } },
261+
status: :service_unavailable
262+
end
263+
264+
# POST /api/v1/competitive/pro-matches/recover-missing
265+
# Recover missing games by querying Leaguepedia Cargo API directly.
266+
# Bypasses the ProStaff Scraper — no SCRAPER_API_KEY required.
267+
#
268+
# Flow:
269+
# 1. Fetch all ScoreboardGames for the overview_page from Leaguepedia
270+
# 2. Filter to games involving our_team
271+
# 3. Skip games already present in the DB (by external_match_id)
272+
# 4. For each missing game, fetch ScoreboardPlayers and import
273+
#
274+
# @param overview_page [String] required — Leaguepedia OverviewPage
275+
# @param our_team [String] required — team name as in Leaguepedia
276+
# @param stage [String] optional — filter to a specific stage
277+
def recover_missing
278+
overview_page = params.require(:overview_page)
279+
our_team = params[:our_team].presence
280+
raise ActionController::ParameterMissing.new(:our_team) if our_team.blank?
281+
282+
stage = params[:stage].presence
283+
284+
service = ::Competitive::Services::LeaguepediaRecoveryService.new(current_organization)
285+
result = service.recover_missing(
286+
overview_page: overview_page,
287+
our_team: our_team,
288+
stage: stage
289+
)
290+
291+
render json: {
292+
message: "Recovery complete for #{our_team} in #{overview_page}",
293+
data: {
294+
overview_page: overview_page,
295+
our_team: our_team,
296+
stage: stage,
297+
stats: result
298+
}
299+
}, status: :ok
300+
rescue ActionController::ParameterMissing => e
301+
render json: { error: { code: 'MISSING_PARAM', message: e.message } },
302+
status: :unprocessable_entity
303+
rescue StandardError => e
304+
Rails.logger.error "[ProMatches#recover_missing] #{e.message}"
305+
render json: { error: { code: 'LEAGUEPEDIA_ERROR', message: e.message } },
306+
status: :service_unavailable
307+
end
308+
226309
# POST /api/v1/competitive/pro-matches/import
227310
# Import a match from PandaScore to our database
228311
def import

0 commit comments

Comments
 (0)