Skip to content

Commit 3e6fe1f

Browse files
committed
feat: add ai intelligence module
1 parent 8b63324 commit 3e6fe1f

21 files changed

Lines changed: 777 additions & 28 deletions

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ gem 'meilisearch', '~> 0.33'
8484
# LLM Integration for Support Chatbot
8585
gem 'ruby-openai', '~> 7.0'
8686

87+
# Linear algebra for AI draft analysis
88+
gem 'numo-narray', '~> 0.9'
89+
8790
# Structured JSON logging (12-Factor XI)
8891
gem 'lograge'
8992

@@ -114,6 +117,7 @@ end
114117

115118
group :test do
116119
gem 'database_cleaner-active_record'
120+
gem 'pundit-matchers'
117121
gem 'shoulda-matchers'
118122
gem 'simplecov', require: false
119123
gem 'vcr'

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ GEM
238238
nio4r (2.7.4)
239239
nokogiri (1.19.1-x86_64-linux-gnu)
240240
racc (~> 1.4)
241+
numo-narray (0.9.2.1)
241242
ostruct (0.6.3)
242243
parallel (1.27.0)
243244
parser (3.3.9.0)
@@ -256,6 +257,11 @@ GEM
256257
nio4r (~> 2.0)
257258
pundit (2.5.2)
258259
activesupport (>= 3.0.0)
260+
pundit-matchers (4.0.0)
261+
rspec-core (~> 3.12)
262+
rspec-expectations (~> 3.12)
263+
rspec-mocks (~> 3.12)
264+
rspec-support (~> 3.12)
259265
raabro (1.4.0)
260266
racc (1.8.1)
261267
rack (3.1.20)
@@ -450,9 +456,11 @@ DEPENDENCIES
450456
kaminari
451457
lograge
452458
meilisearch (~> 0.33)
459+
numo-narray (~> 0.9)
453460
pg (~> 1.1)
454461
puma (~> 6.0)
455462
pundit
463+
pundit-matchers
456464
rack (~> 3.1.20)
457465
rack-attack
458466
rack-cors
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
# WebSocket channel for real-time draft analysis.
4+
# Frontend connects with: { channel: 'DraftChannel', draft_id: '<id>' }
5+
# Authentication is handled by ApplicationCable::Connection (JWT via ?token= query param).
6+
class DraftChannel < ApplicationCable::Channel
7+
def subscribed
8+
draft_id = params[:draft_id]
9+
reject and return if draft_id.blank?
10+
11+
stream_from "draft_#{draft_id}"
12+
end
13+
14+
def unsubscribed
15+
stop_all_streams
16+
end
17+
18+
# Client sends: { team_a: [...], team_b: [...] }
19+
def picks_updated(data)
20+
team_a = data['team_a'].presence || []
21+
team_b = data['team_b'].presence || []
22+
23+
return unless team_a.any? || team_b.any?
24+
25+
result = DraftAnalyzer.call(team_a:, team_b:)
26+
27+
ActionCable.server.broadcast("draft_#{params[:draft_id]}", {
28+
type: 'ai_update',
29+
payload: DraftAnalysisBlueprint.render_as_hash(result)
30+
})
31+
end
32+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module AiIntelligence
4+
module Controllers
5+
# REST endpoint for AI draft analysis.
6+
# Requires Tier 1 (Professional) subscription — feature: predictive_analytics.
7+
class DraftController < Api::V1::BaseController
8+
before_action :require_predictive_analytics_access!
9+
10+
# POST /api/v1/ai/draft/analyze
11+
def analyze
12+
result = DraftAnalyzer.call(
13+
team_a: params.require(:team_a),
14+
team_b: params.require(:team_b),
15+
patch: params[:patch]
16+
)
17+
render_success(DraftAnalysisBlueprint.render_as_hash(result))
18+
end
19+
20+
private
21+
22+
def require_predictive_analytics_access!
23+
return if current_organization.can_access?('predictive_analytics')
24+
25+
render_error(
26+
message: 'AI draft analysis requires Tier 1 (Professional) subscription',
27+
code: 'UPGRADE_REQUIRED',
28+
status: :forbidden
29+
)
30+
end
31+
end
32+
end
33+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module AiIntelligence
4+
# Rebuilds champion matrices and vectors from all CompetitiveMatch records.
5+
# Runs in low_priority queue — triggered after each scraper sync or nightly via sidekiq-scheduler.
6+
# Uses CompetitiveMatch.unscoped intentionally (global dataset, no org context needed).
7+
class RebuildChampionMatrixJob < ApplicationJob
8+
queue_as :low_priority
9+
10+
def perform(scope: :all, league: nil)
11+
Rails.logger.info("[AI] Starting champion matrix rebuild scope=#{scope} league=#{league}")
12+
13+
ChampionMatrixBuilder.call(scope: scope.to_sym, league:)
14+
ChampionVectorBuilder.rebuild_all!
15+
16+
Rails.logger.info("[AI] Champion matrices rebuilt at #{Time.current}")
17+
end
18+
end
19+
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
# Stores historical win-rate data between pairs of champions.
4+
# Global table (no organization_id, no RLS) — aggregates public competitive tournament data.
5+
class AiChampionMatrix < ApplicationRecord
6+
validates :champion_a, :champion_b, presence: true
7+
validates :champion_a, uniqueness: { scope: %i[champion_b patch league] }
8+
9+
scope :with_sufficient_sample, -> { where('total_games >= ?', 10) }
10+
11+
def self.upsert_win(winner, loser, patch: nil, league: nil)
12+
matrix = find_or_initialize_by(champion_a: winner, champion_b: loser, patch: patch, league: league)
13+
matrix.wins_a = matrix.wins_a.to_i + 1
14+
matrix.total_games = matrix.total_games.to_i + 1
15+
matrix.updated_at = Time.current
16+
matrix.save!
17+
end
18+
19+
def win_rate
20+
return 0.5 if total_games.zero?
21+
22+
wins_a.to_f / total_games
23+
end
24+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
# Stores normalized 5-dimensional performance vectors per champion.
4+
# Dimensions: [win_rate, avg_kda, avg_damage_share, avg_gold_share, avg_cs]
5+
# Global table (no organization_id, no RLS).
6+
class AiChampionVector < ApplicationRecord
7+
validates :champion_name, presence: true, uniqueness: true
8+
9+
def vector
10+
Numo::DFloat[*vector_data]
11+
end
12+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
# Serializes DraftAnalyzer::Result for the POST /api/v1/ai/draft/analyze response.
4+
class DraftAnalysisBlueprint < Blueprinter::Base
5+
field :win_probability
6+
field :confidence
7+
field :low_sample
8+
9+
field :top_synergies do |result|
10+
result.synergy_scores
11+
.sort_by { |_, v| -v[:score].to_f }
12+
.first(5)
13+
.map { |(a, b), v| { pair: [a, b], score: v[:score], games: v[:games] } }
14+
end
15+
16+
field :top_counters do |result|
17+
result.counter_scores
18+
.sort_by { |_, v| -v[:advantage].to_f.abs }
19+
.first(5)
20+
.map do |(a, b), v|
21+
{ matchup: [a, b], advantage: v[:advantage], games: v[:games],
22+
confidence: v[:confidence] }
23+
end
24+
end
25+
26+
field :suggested_picks do |result|
27+
result.suggested_picks || []
28+
end
29+
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
# Reads CompetitiveMatch records (via unscoped) and builds the ai_champion_matrices table.
4+
# victory=true means our_picks won; victory=false means opponent_picks won.
5+
class ChampionMatrixBuilder
6+
def initialize(scope: :all, league: nil)
7+
@scope = scope
8+
@league = league
9+
end
10+
11+
def self.call(scope: :all, league: nil)
12+
new(scope:, league:).build
13+
end
14+
15+
def build
16+
AiChampionMatrix.delete_all if @scope == :all
17+
18+
query = CompetitiveMatch.unscoped
19+
query = query.where(tournament_name: @league) if @league
20+
21+
query.find_each do |match|
22+
winner_picks = match.victory ? match.our_picks : match.opponent_picks
23+
loser_picks = match.victory ? match.opponent_picks : match.our_picks
24+
25+
next if winner_picks.blank? || loser_picks.blank?
26+
27+
register_matchups(winner_picks, loser_picks)
28+
end
29+
end
30+
31+
private
32+
33+
def register_matchups(winner_picks, loser_picks)
34+
winner_champions = winner_picks.map { |p| p['champion'] }.compact
35+
loser_champions = loser_picks.map { |p| p['champion'] }.compact
36+
37+
winner_champions.each do |winner|
38+
loser_champions.each do |loser|
39+
AiChampionMatrix.upsert_win(winner, loser)
40+
record_appearance(loser, winner)
41+
end
42+
end
43+
end
44+
45+
def record_appearance(champion_a, champion_b)
46+
AiChampionMatrix
47+
.find_or_initialize_by(champion_a:, champion_b:)
48+
.tap do |m|
49+
m.total_games = m.total_games.to_i + 1
50+
m.updated_at = Time.current
51+
m.save!
52+
end
53+
end
54+
end

0 commit comments

Comments
 (0)