first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
import { ApiProperty } from '@nestjs/swagger';
export type SignalTier =
| 'CORE'
| 'VALUE'
| 'LEAN'
| 'LONGSHOT'
| 'PASS';
export class MatchInfoDto {
@ApiProperty()
match_id: string;
@ApiProperty()
match_name: string;
@ApiProperty()
home_team: string;
@ApiProperty()
away_team: string;
@ApiProperty()
league: string;
@ApiProperty()
match_date_ms: number;
@ApiProperty({ required: false, nullable: true })
league_id?: string | null;
@ApiProperty({ required: false, default: false })
is_top_league?: boolean;
@ApiProperty({
required: false,
enum: ['football', 'basketball'],
})
sport?: 'football' | 'basketball';
}
export class DataQualityDto {
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
label: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty()
score: number;
@ApiProperty()
home_lineup_count: number;
@ApiProperty()
away_lineup_count: number;
@ApiProperty({ required: false, default: 'none' })
lineup_source?: string;
@ApiProperty({ type: [String] })
flags: string[];
}
export class ConfidenceIntervalDto {
@ApiProperty()
lower: number;
@ApiProperty()
upper: number;
@ApiProperty()
width: number;
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
band: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty()
threshold_met: boolean;
}
export class RiskDto {
@ApiProperty({ enum: ['LOW', 'MEDIUM', 'HIGH', 'EXTREME'] })
level: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
@ApiProperty()
score: number;
@ApiProperty()
is_surprise_risk: boolean;
@ApiProperty({ nullable: true })
surprise_type: string | null;
@ApiProperty({ required: false, default: 0 })
surprise_score?: number;
@ApiProperty({ required: false, nullable: true })
surprise_comment?: string | null;
@ApiProperty({ type: [String], required: false })
surprise_reasons?: string[];
@ApiProperty({ type: [String] })
warnings: string[];
}
export class EngineBreakdownDto {
@ApiProperty()
team: number;
@ApiProperty()
player: number;
@ApiProperty()
odds: number;
@ApiProperty()
referee: number;
}
export class MatchPickDto {
@ApiProperty()
market: string;
@ApiProperty()
pick: string;
@ApiProperty()
probability: number;
@ApiProperty()
confidence: number;
@ApiProperty()
odds: number;
@ApiProperty()
raw_confidence: number;
@ApiProperty()
calibrated_confidence: number;
@ApiProperty()
min_required_confidence: number;
@ApiProperty()
edge: number;
@ApiProperty({ required: false, default: 0 })
ev_edge?: number;
@ApiProperty({ required: false, default: 0 })
implied_prob?: number;
@ApiProperty()
play_score: number;
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty()
stake_units: number;
@ApiProperty({ type: [String] })
decision_reasons: string[];
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
confidence_interval?: ConfidenceIntervalDto;
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
})
signal_tier?: SignalTier;
}
export class MatchBetAdviceDto {
@ApiProperty()
playable: boolean;
@ApiProperty()
suggested_stake_units: number;
@ApiProperty()
reason: string;
@ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] })
confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty({ required: false })
min_confidence_for_play?: number;
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
})
signal_tier?: SignalTier;
}
export class MatchBetSummaryItemDto {
@ApiProperty()
market: string;
@ApiProperty()
pick: string;
@ApiProperty()
raw_confidence: number;
@ApiProperty()
calibrated_confidence: number;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty()
playable: boolean;
@ApiProperty()
stake_units: number;
@ApiProperty()
play_score: number;
@ApiProperty({ required: false, default: 0 })
ev_edge?: number;
@ApiProperty({ required: false, default: 0 })
implied_prob?: number;
@ApiProperty({ required: false, default: 0 })
odds?: number;
@ApiProperty({ type: [String] })
reasons: string[];
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
confidence_interval?: ConfidenceIntervalDto;
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
})
signal_tier?: SignalTier;
}
export class HtFtPredictionDto {
@ApiProperty()
'1/1': number;
@ApiProperty()
'1/X': number;
@ApiProperty()
'1/2': number;
@ApiProperty()
'X/1': number;
@ApiProperty()
'X/X': number;
@ApiProperty()
'X/2': number;
@ApiProperty()
'2/1': number;
@ApiProperty()
'2/X': number;
@ApiProperty()
'2/2': number;
@ApiProperty()
pick: string;
@ApiProperty()
confidence: number;
}
export class AggressivePickDto {
@ApiProperty()
market: string;
@ApiProperty()
pick: string;
@ApiProperty()
probability: number;
@ApiProperty()
confidence: number;
@ApiProperty()
odds: number;
@ApiProperty()
raw_confidence: number;
@ApiProperty()
calibrated_confidence: number;
@ApiProperty()
min_required_confidence: number;
@ApiProperty()
edge: number;
@ApiProperty({ required: false, default: 0 })
ev_edge?: number;
@ApiProperty({ required: false, default: 0 })
implied_prob?: number;
@ApiProperty()
play_score: number;
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty()
stake_units: number;
@ApiProperty({ type: [String] })
decision_reasons: string[];
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
confidence_interval?: ConfidenceIntervalDto;
}
export class ScenarioTop5ItemDto {
@ApiProperty()
scenario: string;
@ApiProperty()
score: number;
@ApiProperty()
probability: number;
}
export class ScorePredictionDto {
@ApiProperty()
ft: string;
@ApiProperty()
ht: string;
@ApiProperty()
xg_home: number;
@ApiProperty()
xg_away: number;
@ApiProperty()
xg_total: number;
}
export class MatchPredictionDto {
@ApiProperty()
model_version: string;
@ApiProperty({ type: MatchInfoDto })
match_info: MatchInfoDto;
@ApiProperty({ type: DataQualityDto })
data_quality: DataQualityDto;
@ApiProperty({ type: RiskDto })
risk: RiskDto;
@ApiProperty({ type: EngineBreakdownDto })
engine_breakdown: EngineBreakdownDto;
@ApiProperty({ type: MatchPickDto, nullable: true })
main_pick: MatchPickDto | null;
@ApiProperty({ type: MatchPickDto, nullable: true })
value_pick: MatchPickDto | null;
@ApiProperty({ type: MatchBetAdviceDto })
bet_advice: MatchBetAdviceDto;
@ApiProperty({ type: [MatchBetSummaryItemDto] })
bet_summary: MatchBetSummaryItemDto[];
@ApiProperty({ type: [MatchPickDto] })
supporting_picks: MatchPickDto[];
@ApiProperty({ type: AggressivePickDto, nullable: true })
aggressive_pick: AggressivePickDto | null;
@ApiProperty({ type: HtFtPredictionDto, required: false })
htft?: HtFtPredictionDto;
@ApiProperty({ type: [ScenarioTop5ItemDto] })
scenario_top5: ScenarioTop5ItemDto[];
@ApiProperty({ type: ScorePredictionDto })
score_prediction: ScorePredictionDto;
@ApiProperty({ type: Object })
market_board: Record<string, unknown>;
@ApiProperty({ type: [String] })
reasoning_factors: string[];
}
export class ValueBetDto {
@ApiProperty()
matchId: string;
@ApiProperty()
matchName: string;
@ApiProperty()
betType: string;
@ApiProperty()
prediction: string;
@ApiProperty()
confidence: number;
@ApiProperty()
odd: number;
@ApiProperty()
expectedValue: number;
}
export class PredictionHistoryStatsDto {
@ApiProperty()
totalPredictions: number;
@ApiProperty()
totalResolved: number;
@ApiProperty()
correctPredictions: number;
@ApiProperty()
accuracyRate: number;
}
export class PredictionHistoryResponseDto {
@ApiProperty({ type: PredictionHistoryStatsDto })
stats: PredictionHistoryStatsDto;
@ApiProperty({ type: [Object] })
history: Record<string, unknown>[];
}
export class UpcomingPredictionsDto {
@ApiProperty()
count: number;
@ApiProperty({ type: [MatchPredictionDto] })
matches: MatchPredictionDto[];
@ApiProperty()
modelVersion: string;
}
export class AIHealthDto {
@ApiProperty()
status: string;
@ApiProperty()
modelLoaded: boolean;
@ApiProperty()
predictionServiceReady: boolean;
}
export * from './smart-coupon.dto';
@@ -0,0 +1,63 @@
import {
IsArray,
IsString,
IsOptional,
IsNotEmpty,
IsNumber,
IsEnum,
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class GeneratePredictionDto {
@ApiProperty({ description: 'Match ID to generate prediction for' })
@IsString()
@IsNotEmpty()
matchId: string;
}
export enum CouponStrategy {
SAFE = 'SAFE',
BALANCED = 'BALANCED',
AGGRESSIVE = 'AGGRESSIVE',
VALUE = 'VALUE',
MIRACLE = 'MIRACLE',
}
export class SmartCouponRequestDto {
@ApiProperty({
description: 'List of match IDs for coupon',
example: ['match-1', 'match-2'],
})
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(50)
matchIds: string[];
@ApiPropertyOptional({
enum: CouponStrategy,
default: CouponStrategy.BALANCED,
})
@IsOptional()
@IsEnum(CouponStrategy)
strategy?: CouponStrategy;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(20)
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
example: 60,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
minConfidence?: number;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Smart Coupon DTOs aligned with AI Engine V20+ contract.
*/
export type CouponStrategy =
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE';
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW';
export interface SmartCouponRequestDto {
match_ids: string[];
strategy?: CouponStrategy;
max_matches?: number;
min_confidence?: number;
}
export interface CouponBetDto {
match_id: string;
match_name: string;
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
risk_level: RiskLevel;
data_quality: DataQualityLabel;
}
export interface RejectedMatchDto {
match_id: string;
reason: string;
threshold?: number;
}
export interface SmartCouponResponseDto {
strategy: CouponStrategy;
generated_at: string;
match_count: number;
bets: CouponBetDto[];
total_odds: number;
expected_win_rate: number;
rejected_matches: RejectedMatchDto[];
}
export interface SmartCouponApiError {
error: string;
detail?: string;
match_ids_failed?: string[];
}
export interface StrategyInfo {
name: CouponStrategy;
description: string;
typical_odds: string;
}
export interface StrategiesResponseDto {
strategies: StrategyInfo[];
}
+169
View File
@@ -0,0 +1,169 @@
import {
Controller,
Get,
Post,
Body,
Param,
HttpCode,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { PredictionsService } from './predictions.service';
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} from './dto';
import {
GeneratePredictionDto,
SmartCouponRequestDto,
} from './dto/predictions-request.dto';
import { Public } from 'src/common/decorators';
@ApiTags('Predictions')
@Controller('predictions')
export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
/**
* GET /predictions/health
* Check AI Engine health status
*/
@Get('health')
@ApiOperation({ summary: 'Check AI Engine health status' })
@ApiResponse({ status: 200, type: AIHealthDto })
async checkHealth(): Promise<AIHealthDto> {
return this.predictionsService.checkHealth();
}
/**
* GET /predictions/upcoming
* Get predictions for upcoming matches
*/
@Get('upcoming')
@ApiOperation({ summary: 'Get predictions for upcoming matches' })
@ApiResponse({ status: 200, type: UpcomingPredictionsDto })
async getUpcoming(): Promise<UpcomingPredictionsDto> {
return this.predictionsService.getUpcomingPredictions();
}
/**
* GET /predictions/test/:id
* Refetch match data and get prediction
*/
@Get('test/:id')
@ApiOperation({ summary: 'Refetch match data and get prediction' })
@ApiParam({ name: 'id', description: 'Match ID' })
async getTestPrediction(@Param('id') id: string) {
return this.predictionsService.testPrediction(id);
}
/**
* GET /predictions/value-bets
* Get EV+ betting opportunities
*/
@Get('value-bets')
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' })
@ApiResponse({ status: 200, type: [ValueBetDto] })
async getValueBets(): Promise<ValueBetDto[]> {
return this.predictionsService.getValueBets();
}
/**
* GET /predictions/history
* Get prediction history and accuracy stats
*/
@Get('history')
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' })
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
async getHistory(): Promise<PredictionHistoryResponseDto> {
return this.predictionsService.getPredictionHistory();
}
/**
* GET /predictions/:matchId
* Get prediction for a specific match
*/
@Get(':matchId')
@Public()
@ApiOperation({ summary: 'Get prediction for a specific match' })
@ApiParam({ name: 'matchId', description: 'Match ID' })
@ApiResponse({ status: 200, type: MatchPredictionDto })
@ApiResponse({ status: 404, description: 'Match not found' })
async getPrediction(
@Param('matchId') matchId: string,
): Promise<MatchPredictionDto> {
// Check cache first
const cached = await this.predictionsService.getCachedPrediction(matchId);
if (cached) {
return cached;
}
// Get from AI Engine
const prediction = await this.predictionsService.getPredictionById(matchId);
if (!prediction) {
throw new NotFoundException(`Match not found: ${matchId}`);
}
// Cache the result
await this.predictionsService.cachePrediction(matchId, prediction);
return prediction;
}
/**
* POST /predictions/generate
* Generate prediction with provided match data
*/
@Post('generate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate prediction with provided match data' })
@ApiResponse({ status: 200, type: MatchPredictionDto })
async generatePrediction(
@Body() dto: GeneratePredictionDto,
): Promise<MatchPredictionDto> {
const prediction = await this.predictionsService.getPredictionWithData({
matchId: dto.matchId,
});
if (!prediction) {
throw new NotFoundException('Failed to generate prediction');
}
return prediction;
}
/**
* POST /predictions/smart-coupon
* Generate Smart Coupon using AI Engine V20
*/
@Post('smart-coupon')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate Smart Coupon with V20 AI recommendations',
})
@ApiResponse({
status: 200,
description: 'Smart coupon generated successfully',
})
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon(
dto.matchIds,
dto.strategy || 'BALANCED',
{
maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence,
},
);
if (!coupon) {
throw new NotFoundException('Failed to generate Smart Coupon');
}
return coupon;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { BullModule } from '@nestjs/bullmq';
import { PredictionsController } from './predictions.controller';
import { PredictionsService } from './predictions.service';
import { AiFeatureStoreService } from './services/ai-feature-store.service';
import { DatabaseModule } from '../../database/database.module';
import { MatchesModule } from '../matches/matches.module';
import { PredictionsQueue } from './queues/predictions.queue';
import { PredictionsProcessor } from './queues/predictions.processor';
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
import { FeederModule } from '../feeder/feeder.module';
const redisEnabled = process.env.REDIS_ENABLED === 'true';
@Module({
imports: [
DatabaseModule,
HttpModule.register({
timeout: 30000, // 30 seconds
maxRedirects: 5,
}),
...(redisEnabled
? [BullModule.registerQueue({ name: PREDICTIONS_QUEUE })]
: []),
MatchesModule,
FeederModule,
],
controllers: [PredictionsController],
providers: [
PredictionsService,
AiFeatureStoreService,
...(redisEnabled ? [PredictionsQueue, PredictionsProcessor] : []),
],
exports: [PredictionsService, AiFeatureStoreService],
})
export class PredictionsModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/unbound-method */
import axios from 'axios';
import { PredictionJobType } from './predictions.types';
import { PredictionsProcessor } from './predictions.processor';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('PredictionsProcessor', () => {
let processor: PredictionsProcessor;
beforeEach(() => {
jest.clearAllMocks();
process.env.AI_ENGINE_URL = 'http://unit-ai:8000';
processor = new PredictionsProcessor();
});
afterEach(() => {
delete process.env.AI_ENGINE_URL;
});
it('posts to analyze endpoint for predict-match jobs', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
const job = {
id: 'j1',
name: PredictionJobType.PREDICT_MATCH,
data: { matchId: 'match-123' },
} as any;
const result = await processor.process(job);
expect(result).toEqual({ ok: true });
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/analyze/match-123',
{},
{ timeout: 30000 },
);
});
it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
const job = {
id: 'j2',
name: PredictionJobType.SMART_COUPON,
data: {
matchIds: ['m1', 'm2'],
strategy: 'BALANCED',
options: { maxMatches: 4, minConfidence: 65 },
},
} as any;
const result = await processor.process(job);
expect(result).toEqual({ bets: [] });
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/coupon',
{
match_ids: ['m1', 'm2'],
strategy: 'BALANCED',
max_matches: 4,
min_confidence: 65,
},
{ timeout: 60000 },
);
});
it('throws for unknown job type', async () => {
const job = {
id: 'j3',
name: 'unknown-job',
data: {},
} as any;
await expect(processor.process(job)).rejects.toThrow(
'Unknown job type: unknown-job',
);
});
});
+123
View File
@@ -0,0 +1,123 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import {
PREDICTIONS_QUEUE,
PredictionJobType,
PredictMatchJobData,
SmartCouponJobData,
} from './predictions.types';
import axios from 'axios';
/**
* Predictions Processor
* Handles heavy AI computations in background via HTTP calls to AI Engine
*/
@Processor(PREDICTIONS_QUEUE)
export class PredictionsProcessor extends WorkerHost {
private readonly logger = new Logger(PredictionsProcessor.name);
private readonly aiEngineUrl: string;
constructor() {
super();
// Default to container service URL
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
}
async process(job: Job<any, any, string>): Promise<any> {
this.logger.debug(`Processing job ${job.id}: ${job.name}`);
switch (job.name) {
case PredictionJobType.PREDICT_MATCH:
return this.handlePredictMatch(job.data as PredictMatchJobData);
case PredictionJobType.SMART_COUPON:
return this.handleSmartCoupon(job.data as SmartCouponJobData);
default:
throw new Error(`Unknown job type: ${job.name}`);
}
}
/**
* Handle Single Match Prediction
* HTTP POST /v20plus/analyze/:id
*/
private async handlePredictMatch(data: PredictMatchJobData): Promise<any> {
const { matchId } = data;
this.logger.log(`🤖 AI Engine: Predicting match ${matchId}...`);
try {
const response = await axios.post(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
{},
{ timeout: 30000 },
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchId, 'predict');
}
}
/**
* Handle Smart Coupon Generation
* HTTP POST /v20plus/coupon
*/
private async handleSmartCoupon(data: SmartCouponJobData): Promise<any> {
const { matchIds, strategy } = data;
this.logger.log(`🎫 AI Engine: Generating ${strategy} Coupon...`);
try {
const response = await axios.post(
`${this.aiEngineUrl}/v20plus/coupon`,
{
match_ids: matchIds,
strategy,
max_matches: data.options?.maxMatches,
min_confidence: data.options?.minConfidence,
},
{ timeout: 60000 },
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon');
}
}
private mapAxiosError(
error: unknown,
identifier: string,
flow: 'predict' | 'smart-coupon',
): Error {
if (!axios.isAxiosError(error)) {
return error instanceof Error
? error
: new Error(`AI_ENGINE_UNKNOWN|${flow}|Unknown error`);
}
const status = error.response?.status;
const detail = error.response?.data?.detail || error.message;
const code = error.code || '';
if (status === 502) {
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_502|${flow}|${detail}`);
}
if (status === 504) {
this.logger.error(`AI Engine 504 (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_504|${flow}|${detail}`);
}
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') {
this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`);
return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`);
}
this.logger.error(
`AI Engine error (${flow}:${identifier}) [${status ?? 'N/A'}]: ${detail}`,
);
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import {
PREDICTIONS_QUEUE,
PredictionJobType,
PredictMatchJobData,
SmartCouponJobData,
} from './predictions.types';
@Injectable()
export class PredictionsQueue {
private readonly logger = new Logger(PredictionsQueue.name);
constructor(
@InjectQueue(PREDICTIONS_QUEUE)
public readonly queue: Queue,
) {}
/**
* Add a single match prediction job
*/
async addPredictMatchJob(data: PredictMatchJobData) {
this.logger.debug(`Adding prediction job for match: ${data.matchId}`);
return this.queue.add(PredictionJobType.PREDICT_MATCH, data, {
priority: 1, // High priority
});
}
/**
* Add a smart coupon generation job
*/
async addSmartCouponJob(data: SmartCouponJobData) {
this.logger.debug(
`Adding smart coupon job: ${data.strategy} (${data.matchIds.length} matches)`,
);
return this.queue.add(PredictionJobType.SMART_COUPON, data, {
priority: 5, // Lower priority than single predictions
});
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Prediction Queue Types
* Senior Level Strict Typing
*/
export const PREDICTIONS_QUEUE = 'predictions-queue';
export enum PredictionJobType {
PREDICT_MATCH = 'predict-match',
SMART_COUPON = 'smart-coupon',
}
export interface PredictMatchJobData {
matchId: string;
forceUpdate?: boolean;
}
export interface SmartCouponJobData {
matchIds: string[];
strategy: string;
options?: {
maxMatches?: number;
minConfidence?: number;
};
}
export type PredictionJob =
| { type: PredictionJobType.PREDICT_MATCH; data: PredictMatchJobData }
| { type: PredictionJobType.SMART_COUPON; data: SmartCouponJobData };
@@ -0,0 +1,114 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
@Injectable()
export class AiFeatureStoreService {
private readonly logger = new Logger(AiFeatureStoreService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Bir maç için AI özelliklerini hesaplar ve 'match_ai_features' tablosuna yazar.
* Bu metod Feeder yeni veri çektiğinde tetiklenmelidir.
*/
async calculateAndSaveFeatures(matchId: string): Promise<void> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: {
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
},
awayTeam: {
include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
},
},
});
if (!match || !match.homeTeam || !match.awayTeam) return;
// 1. Form Score Calculation (0-100)
// Son 5 maçtaki galibiyet, beraberlik ve atılan gollerin ağırlıklı ortalaması
const homeForm = this.calculateFormScore(match.homeTeam.homeMatches);
const awayForm = this.calculateFormScore(match.awayTeam.awayMatches);
// 2. ELO — Read from team_elo_ratings table (populated by AI Engine compute_elo.py)
const homeElo = match.homeTeamId
? await this.getTeamElo(match.homeTeamId)
: 1500.0;
const awayElo = match.awayTeamId
? await this.getTeamElo(match.awayTeamId)
: 1500.0;
// 3. Missing Player Impact (Sakat/Cezalı etkisi)
// Feeder'dan gelen lineups verisindeki eksik as oyuncuları analiz etmeliyiz.
// Şimdilik 0.0 (Etkisiz) olarak set ediyoruz, ilerde Lineup analizi buraya eklenecek.
const missingImpact = 0.0;
// 4. Save to Feature Store
await this.prisma.footballAiFeature.upsert({
where: { matchId },
update: {
homeElo,
awayElo,
homeFormScore: homeForm,
awayFormScore: awayForm,
missingPlayersImpact: missingImpact,
updatedAt: new Date(),
},
create: {
matchId,
homeElo,
awayElo,
homeFormScore: homeForm,
awayFormScore: awayForm,
missingPlayersImpact: missingImpact,
},
});
this.logger.debug(
`Features calculated for match ${matchId} (Home Form: ${homeForm}, Away Form: ${awayForm})`,
);
}
/**
* Form Puanı Hesaplama Algoritması (V17 Simplified)
* W=30, D=10, L=0 puan. + Gol başına 5 puan (max 15).
* Toplam skor 0-100 arasına normalize edilir.
*/
private calculateFormScore(matches: any[]): number {
if (!matches || matches.length === 0) return 50; // Nötr form
let totalPoints = 0;
const maxPoints = matches.length * 45; // Max olası puan (30win + 15goal)
for (const m of matches) {
// Skor kontrolü (bazı maçlar oynanmamış olabilir)
if (m.scoreHome === null || m.scoreAway === null) continue;
const isWin = m.scoreHome > m.scoreAway; // Home team context
const isDraw = m.scoreHome === m.scoreAway;
if (isWin) totalPoints += 30;
else if (isDraw) totalPoints += 10;
const goals = Math.min(m.scoreHome, 3); // Max 3 gol katkısı
totalPoints += goals * 5;
}
// Normalize to 0-100
// Eğer hiç maç oynanmadıysa yine 50 dön.
return matches.length > 0 ? (totalPoints / maxPoints) * 100 : 50;
}
/**
* team_elo_ratings tablosundan takımın güncel ELO puanını okur.
* Kayıt yoksa varsayılan 1500.0 döner.
*/
private async getTeamElo(teamId: string): Promise<number> {
const row = await this.prisma.teamEloRating.findUnique({
where: { teamId },
select: { overallElo: true },
});
return row?.overallElo ?? 1500.0;
}
}