This commit is contained in:
2026-04-16 17:21:48 +03:00
parent c8fa4c442d
commit c8e7e4e927
116 changed files with 3720 additions and 4197 deletions
+32 -37
View File
@@ -1,11 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export type SignalTier =
| 'CORE'
| 'VALUE'
| 'LEAN'
| 'LONGSHOT'
| 'PASS';
export type SignalTier = "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS";
export class MatchInfoDto {
@ApiProperty()
@@ -34,14 +29,14 @@ export class MatchInfoDto {
@ApiProperty({
required: false,
enum: ['football', 'basketball'],
enum: ["football", "basketball"],
})
sport?: 'football' | 'basketball';
sport?: "football" | "basketball";
}
export class DataQualityDto {
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
label: 'HIGH' | 'MEDIUM' | 'LOW';
@ApiProperty({ enum: ["HIGH", "MEDIUM", "LOW"] })
label: "HIGH" | "MEDIUM" | "LOW";
@ApiProperty()
score: number;
@@ -52,7 +47,7 @@ export class DataQualityDto {
@ApiProperty()
away_lineup_count: number;
@ApiProperty({ required: false, default: 'none' })
@ApiProperty({ required: false, default: "none" })
lineup_source?: string;
@ApiProperty({ type: [String] })
@@ -69,16 +64,16 @@ export class ConfidenceIntervalDto {
@ApiProperty()
width: number;
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
band: 'HIGH' | 'MEDIUM' | 'LOW';
@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({ enum: ["LOW", "MEDIUM", "HIGH", "EXTREME"] })
level: "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
@ApiProperty()
score: number;
@@ -156,8 +151,8 @@ export class MatchPickDto {
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty()
stake_units: number;
@@ -170,7 +165,7 @@ export class MatchPickDto {
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
})
signal_tier?: SignalTier;
}
@@ -185,15 +180,15 @@ export class MatchBetAdviceDto {
@ApiProperty()
reason: string;
@ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] })
confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW';
@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'],
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
})
signal_tier?: SignalTier;
}
@@ -211,8 +206,8 @@ export class MatchBetSummaryItemDto {
@ApiProperty()
calibrated_confidence: number;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty()
playable: boolean;
@@ -240,30 +235,30 @@ export class MatchBetSummaryItemDto {
@ApiProperty({
required: false,
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
enum: ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"],
})
signal_tier?: SignalTier;
}
export class HtFtPredictionDto {
@ApiProperty()
'1/1': number;
"1/1": number;
@ApiProperty()
'1/X': number;
"1/X": number;
@ApiProperty()
'1/2': number;
"1/2": number;
@ApiProperty()
'X/1': number;
"X/1": number;
@ApiProperty()
'X/X': number;
"X/X": number;
@ApiProperty()
'X/2': number;
"X/2": number;
@ApiProperty()
'2/1': number;
"2/1": number;
@ApiProperty()
'2/X': number;
"2/X": number;
@ApiProperty()
'2/2': number;
"2/2": number;
@ApiProperty()
pick: string;
@ApiProperty()
@@ -310,8 +305,8 @@ export class AggressivePickDto {
@ApiProperty()
playable: boolean;
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
bet_grade: 'A' | 'B' | 'C' | 'PASS';
@ApiProperty({ enum: ["A", "B", "C", "PASS"] })
bet_grade: "A" | "B" | "C" | "PASS";
@ApiProperty()
stake_units: number;
@@ -468,4 +463,4 @@ export class AIHealthDto {
predictionServiceReady: boolean;
}
export * from './smart-coupon.dto';
export * from "./smart-coupon.dto";
@@ -8,28 +8,28 @@ import {
ArrayMaxSize,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class GeneratePredictionDto {
@ApiProperty({ description: 'Match ID to generate prediction for' })
@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',
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'],
description: "List of match IDs for coupon",
example: ["match-1", "match-2"],
})
@IsArray()
@IsString({ each: true })
@@ -44,7 +44,7 @@ export class SmartCouponRequestDto {
@IsEnum(CouponStrategy)
strategy?: CouponStrategy;
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
@ApiPropertyOptional({ description: "Maximum matches in coupon", example: 5 })
@IsOptional()
@IsNumber()
@Min(1)
@@ -52,7 +52,7 @@ export class SmartCouponRequestDto {
maxMatches?: number;
@ApiPropertyOptional({
description: 'Minimum confidence threshold (0-100)',
description: "Minimum confidence threshold (0-100)",
example: 60,
})
@IsOptional()
@@ -3,14 +3,14 @@
*/
export type CouponStrategy =
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE';
| "SAFE"
| "BALANCED"
| "AGGRESSIVE"
| "VALUE"
| "MIRACLE";
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW';
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "EXTREME";
export type DataQualityLabel = "HIGH" | "MEDIUM" | "LOW";
export interface SmartCouponRequestDto {
match_ids: string[];
@@ -7,24 +7,24 @@ import {
HttpCode,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { PredictionsService } from './predictions.service';
} from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from "@nestjs/swagger";
import { PredictionsService } from "./predictions.service";
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} from './dto';
} from "./dto";
import {
GeneratePredictionDto,
SmartCouponRequestDto,
} from './dto/predictions-request.dto';
import { Public } from 'src/common/decorators';
} from "./dto/predictions-request.dto";
import { Public } from "src/common/decorators";
@ApiTags('Predictions')
@Controller('predictions')
@ApiTags("Predictions")
@Controller("predictions")
export class PredictionsController {
constructor(private readonly predictionsService: PredictionsService) {}
@@ -32,8 +32,8 @@ export class PredictionsController {
* GET /predictions/health
* Check AI Engine health status
*/
@Get('health')
@ApiOperation({ summary: '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();
@@ -43,8 +43,8 @@ export class PredictionsController {
* GET /predictions/upcoming
* Get predictions for upcoming matches
*/
@Get('upcoming')
@ApiOperation({ summary: '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();
@@ -54,10 +54,10 @@ export class PredictionsController {
* 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) {
@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);
}
@@ -65,8 +65,8 @@ export class PredictionsController {
* GET /predictions/value-bets
* Get EV+ betting opportunities
*/
@Get('value-bets')
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' })
@Get("value-bets")
@ApiOperation({ summary: "Get value betting opportunities (EV+)" })
@ApiResponse({ status: 200, type: [ValueBetDto] })
async getValueBets(): Promise<ValueBetDto[]> {
return this.predictionsService.getValueBets();
@@ -76,8 +76,8 @@ export class PredictionsController {
* GET /predictions/history
* Get prediction history and accuracy stats
*/
@Get('history')
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' })
@Get("history")
@ApiOperation({ summary: "Get prediction history and accuracy statistics" })
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
async getHistory(): Promise<PredictionHistoryResponseDto> {
return this.predictionsService.getPredictionHistory();
@@ -87,14 +87,14 @@ export class PredictionsController {
* GET /predictions/:matchId
* Get prediction for a specific match
*/
@Get(':matchId')
@Get(":matchId")
@Public()
@ApiOperation({ summary: 'Get prediction for a specific match' })
@ApiParam({ name: 'matchId', description: 'Match ID' })
@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' })
@ApiResponse({ status: 404, description: "Match not found" })
async getPrediction(
@Param('matchId') matchId: string,
@Param("matchId") matchId: string,
): Promise<MatchPredictionDto> {
// Check cache first
const cached = await this.predictionsService.getCachedPrediction(matchId);
@@ -119,9 +119,9 @@ export class PredictionsController {
* POST /predictions/generate
* Generate prediction with provided match data
*/
@Post('generate')
@Post("generate")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate prediction with provided match data' })
@ApiOperation({ summary: "Generate prediction with provided match data" })
@ApiResponse({ status: 200, type: MatchPredictionDto })
async generatePrediction(
@Body() dto: GeneratePredictionDto,
@@ -131,7 +131,7 @@ export class PredictionsController {
});
if (!prediction) {
throw new NotFoundException('Failed to generate prediction');
throw new NotFoundException("Failed to generate prediction");
}
return prediction;
@@ -141,19 +141,19 @@ export class PredictionsController {
* POST /predictions/smart-coupon
* Generate Smart Coupon using AI Engine V20
*/
@Post('smart-coupon')
@Post("smart-coupon")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Generate Smart Coupon with V20 AI recommendations',
summary: "Generate Smart Coupon with V20 AI recommendations",
})
@ApiResponse({
status: 200,
description: 'Smart coupon generated successfully',
description: "Smart coupon generated successfully",
})
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
const coupon = await this.predictionsService.getSmartCoupon(
dto.matchIds,
dto.strategy || 'BALANCED',
dto.strategy || "BALANCED",
{
maxMatches: dto.maxMatches,
minConfidence: dto.minConfidence,
@@ -161,7 +161,7 @@ export class PredictionsController {
);
if (!coupon) {
throw new NotFoundException('Failed to generate Smart Coupon');
throw new NotFoundException("Failed to generate Smart Coupon");
}
return coupon;
+13 -13
View File
@@ -1,17 +1,17 @@
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';
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';
const redisEnabled = process.env.REDIS_ENABLED === "true";
@Module({
imports: [
+188 -182
View File
@@ -6,26 +6,26 @@ import {
OnModuleDestroy,
OnModuleInit,
Optional,
} from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { ConfigService } from '@nestjs/config';
import { QueueEvents } from 'bullmq';
import { PredictionsQueue } from './queues/predictions.queue';
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
} from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { ConfigService } from "@nestjs/config";
import { QueueEvents } from "bullmq";
import { PredictionsQueue } from "./queues/predictions.queue";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import {
MatchPredictionDto,
PredictionHistoryResponseDto,
UpcomingPredictionsDto,
ValueBetDto,
AIHealthDto,
} from './dto';
import axios, { AxiosError } from 'axios';
import { Prisma } from '@prisma/client';
import { FeederService } from '../feeder/feeder.service';
import * as fs from 'node:fs';
import * as path from 'node:path';
} from "./dto";
import axios, { AxiosError } from "axios";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import * as fs from "node:fs";
import * as path from "node:path";
type ConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW';
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
interface ConfidenceInterval {
lower: number;
@@ -47,73 +47,72 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private readonly aiEngineUrl: string;
private readonly topLeagueIds = new Set<string>();
private readonly reasonTranslations: Record<string, string> = {
confidence_below_threshold: 'Güven eşiğin altında',
confidence_interval_too_wide: 'Güven aralığı çok geniş',
confidence_below_threshold: "Güven eşiğin altında",
confidence_interval_too_wide: "Güven aralığı çok geniş",
confidence_interval_too_wide_for_main_pick:
'Ana seçim için güven aralığı çok geniş',
confidence_band_low: 'Güven bandı düşük',
playable_edge_found: 'Oynanabilir avantaj bulundu',
market_signal_dominant: 'Piyasa sinyali baskın',
team_form_signal_dominant: 'Takım formuna dayalı sinyaller çok baskın',
lineup_signal_strong: 'İlk on bir sinyali güçlü',
lineup_signal_weak: 'İlk on bir sinyali zayıf',
lineup_probable_xi_used: 'Muhtemel ilk on bir kullanıldı',
lineup_probable_not_confirmed: 'Muhtemel ilk on bir henüz doğrulanmadı',
lineup_unavailable: 'İlk on bir bilgisi mevcut değil',
lineup_incomplete: 'İlk on bir bilgisi eksik',
missing_referee: 'Hakem verisi eksik',
draw_probability_elevated: 'Beraberlik olasılığı yükselmiş görünüyor',
balanced_match_risk: 'Maç dengeli, sürpriz riski yükseliyor',
draw_pressure: 'Beraberlik baskısı yüksek',
upset_risk_detected: 'Sürpriz riski tespit edildi',
limited_data_confidence: 'Veri kısıtlı olduğu için güven sınırlı',
data_quality_issue: 'Veri kalitesi sorunu var',
high_risk_low_data_quality: 'Risk yüksek, veri kalitesi düşük',
insufficient_play_score: 'Oynanabilirlik puanı yetersiz',
no_bet_conditions_met: 'Bahis koşulları oluşmadı',
market_passed_all_gates: 'Market tüm güvenlik kontrollerini geçti',
"Ana seçim için güven aralığı çok geniş",
confidence_band_low: "Güven bandı düşük",
playable_edge_found: "Oynanabilir avantaj bulundu",
market_signal_dominant: "Piyasa sinyali baskın",
team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
lineup_signal_strong: "İlk on bir sinyali güçlü",
lineup_signal_weak: "İlk on bir sinyali zayıf",
lineup_probable_xi_used: "Muhtemel ilk on bir kullanıldı",
lineup_probable_not_confirmed: "Muhtemel ilk on bir henüz doğrulanmadı",
lineup_unavailable: "İlk on bir bilgisi mevcut değil",
lineup_incomplete: "İlk on bir bilgisi eksik",
missing_referee: "Hakem verisi eksik",
draw_probability_elevated: "Beraberlik olasılığı yükselmiş görünüyor",
balanced_match_risk: "Maç dengeli, sürpriz riski yükseliyor",
draw_pressure: "Beraberlik baskısı yüksek",
upset_risk_detected: "Sürpriz riski tespit edildi",
limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
data_quality_issue: "Veri kalitesi sorunu var",
high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
insufficient_play_score: "Oynanabilirlik puanı yetersiz",
no_bet_conditions_met: "Bahis koşulları oluşmadı",
market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
no_ev_edge_minimum_stake:
'Beklenen avantaj oluşmadı, minimum bahis önerildi',
player_form_signal_strong: 'Oyuncu formu sinyali güçlü',
player_form_signal_limited: 'Oyuncu formu sinyali sınırlı',
live_state_impossible_market: 'Canlı maç durumu bu marketi geçersiz kılıyor',
live_score_exceeds_under_line:
'Mevcut skor bu alt seçeneğiyle çelişiyor',
"Beklenen avantaj oluşmadı, minimum bahis önerildi",
player_form_signal_strong: "Oyuncu formu sinyali güçlü",
player_form_signal_limited: "Oyuncu formu sinyali sınırlı",
live_state_impossible_market:
"Canlı maç durumu bu marketi geçersiz kılıyor",
live_score_exceeds_under_line: "Mevcut skor bu alt seçeneğiyle çelişiyor",
score_model_conflicts_with_under_pick:
'Skor modeli alt seçeneğiyle çelişiyor',
"Skor modeli alt seçeneğiyle çelişiyor",
score_model_conflicts_with_over_pick:
'Skor modeli üst seçeneğiyle çelişiyor',
"Skor modeli üst seçeneğiyle çelişiyor",
market_stack_conflict_over25:
'Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı',
"Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı",
market_stack_conflict_btts:
'Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı',
"Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı",
first_half_result_conflicts_with_goalless_half:
'İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor',
"İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_htft_conflicts_with_goalless_half:
'İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor',
"İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor",
first_half_draw_conflicts_with_goal_pick:
'İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor',
"İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor",
first_half_goalless_conflicts_with_result_pick:
'Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor',
"Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor",
first_half_goalless_conflicts_with_htft_pick:
'Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor',
"Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor",
first_half_goal_pressure_conflicts_with_htft_draw:
'İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor',
live_total_goals_close_to_line:
'Canlı toplam gol çizgisine çok yakın',
"İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor",
live_total_goals_close_to_line: "Canlı toplam gol çizgisine çok yakın",
score_model_conflicts_with_btts_no:
'Skor modeli KG Yok seçeneğiyle çelişiyor',
"Skor modeli KG Yok seçeneğiyle çelişiyor",
score_model_conflicts_with_draw_pick:
'Skor modeli beraberlik seçeneğiyle çelişiyor',
"Skor modeli beraberlik seçeneğiyle çelişiyor",
score_model_conflicts_with_home_pick:
'Skor modeli ev sahibi seçeneğiyle çelişiyor',
"Skor modeli ev sahibi seçeneğiyle çelişiyor",
score_model_conflicts_with_away_pick:
'Skor modeli deplasman seçeneğiyle çelişiyor',
high_total_goal_volatility: 'Toplam gol volatilitesi yüksek',
mutual_goal_pressure: 'İki takımın da gol baskısı yüksek',
late_goal_swing_risk: 'Geç gol kaynaklı kırılma riski var',
live_match_open_state: 'Canlı maç açık oyuna dönmüş durumda',
live_match_active_state: 'Canlı maç aktif ve dalgalı ilerliyor',
"Skor modeli deplasman seçeneğiyle çelişiyor",
high_total_goal_volatility: "Toplam gol volatilitesi yüksek",
mutual_goal_pressure: "İki takımın da gol baskısı yüksek",
late_goal_swing_risk: "Geç gol kaynaklı kırılma riski var",
live_match_open_state: "Canlı maç açık oyuna dönmüş durumda",
live_match_active_state: "Canlı maç aktif ve dalgalı ilerliyor",
};
constructor(
@@ -123,8 +122,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
@Optional() private readonly predictionsQueue?: PredictionsQueue,
) {
this.aiEngineUrl = this.configService.get(
'AI_ENGINE_URL',
'http://localhost:8000',
"AI_ENGINE_URL",
"http://localhost:8000",
);
this.topLeagueIds = this.loadTopLeagueIds();
}
@@ -133,14 +132,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (this.predictionsQueue) {
this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
connection: {
host: this.configService.get('redis.host', 'localhost'),
port: this.configService.get('redis.port', 6379),
password: this.configService.get('redis.password'),
host: this.configService.get("redis.host", "localhost"),
port: this.configService.get("redis.port", 6379),
password: this.configService.get("redis.password"),
},
});
this.logger.log('Queue mode enabled for predictions');
this.logger.log("Queue mode enabled for predictions");
} else {
this.logger.log('Direct HTTP mode enabled for predictions (no Redis)');
this.logger.log("Direct HTTP mode enabled for predictions (no Redis)");
}
}
@@ -152,7 +151,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
checkHealth(): Promise<AIHealthDto> {
return Promise.resolve({
status: 'healthy',
status: "healthy",
modelLoaded: true,
predictionServiceReady: true,
});
@@ -212,12 +211,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
if (status === 422) {
throw new HttpException(
`AI Engine: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
`AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
throw new HttpException(
`AI Engine error: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`,
`AI Engine error: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -232,7 +231,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`);
// refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB
const refreshResult = await this.feederService.refreshMatch(matchId, 'all');
const refreshResult = await this.feederService.refreshMatch(matchId, "all");
if (!refreshResult.success) {
this.logger.warn(
@@ -251,7 +250,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const upcoming = await this.prisma.prediction.findMany({
where: {
match: {
status: 'NS',
status: "NS",
mstUtc: { gte: Math.floor(Date.now() / 1000) },
},
},
@@ -260,13 +259,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
include: { homeTeam: true, awayTeam: true, league: true },
},
},
orderBy: { match: { mstUtc: 'asc' } },
orderBy: { match: { mstUtc: "asc" } },
take: 50,
});
return {
count: upcoming.length,
modelVersion: 'v25-v30-ensemble',
modelVersion: "v25-v30-ensemble",
matches: upcoming.map((p) => {
const out = p.predictionJson as Record<string, unknown>;
const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
@@ -276,9 +275,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
...matchInfo,
match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || '',
league: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ''),
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
@@ -287,12 +286,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private loadTopLeagueIds(): Set<string> {
try {
const topLeaguesPath = path.join(process.cwd(), 'top_leagues.json');
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) {
return new Set<string>();
}
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, 'utf8'));
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
@@ -318,7 +317,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ''),
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
};
}
@@ -329,7 +328,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ''),
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
@@ -346,7 +345,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
league_id:
this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
is_top_league:
this.asRecord(response.match_info).is_top_league ?? matchContext.isTopLeague,
this.asRecord(response.match_info).is_top_league ??
matchContext.isTopLeague,
};
const mainPick = this.enrichPick(
@@ -369,9 +369,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
const supportingPicks = Array.isArray(response.supporting_picks)
? response.supporting_picks.map((pick) =>
this.enrichPick(pick, response, matchContext, marketBoard),
).filter((pick): pick is NonNullable<typeof pick> => pick !== null)
? response.supporting_picks
.map((pick) =>
this.enrichPick(pick, response, matchContext, marketBoard),
)
.filter((pick): pick is NonNullable<typeof pick> => pick !== null)
: [];
const betSummary = Array.isArray(response.bet_summary)
@@ -380,8 +382,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
)
: [];
const mainBand =
this.asRecord(mainPick?.confidence_interval).band ?? 'LOW';
const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
const minConfidenceForPlay = this.getMinConfidenceForPlay(
this.asRecord(mainPick).market,
matchContext.isTopLeague,
@@ -402,7 +403,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (mainPick && !isMainPlayable) {
reasoningFactors.unshift(
this.translateReason('confidence_interval_too_wide_for_main_pick'),
this.translateReason("confidence_interval_too_wide_for_main_pick"),
);
}
@@ -416,9 +417,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
isMainPlayable
? String(
this.asRecord(response.bet_advice).reason ||
'playable_edge_found',
"playable_edge_found",
)
: 'confidence_below_threshold',
: "confidence_below_threshold",
),
suggested_stake_units: isMainPlayable
? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
@@ -428,15 +429,18 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const enrichedMarketBoard = Object.fromEntries(
Object.entries(marketBoard).map(([market, entry]) => {
const record = this.asRecord(entry);
const pickName = String(record.pick ?? '');
if (!pickName || !record.probs || typeof record.probs !== 'object') {
const pickName = String(record.pick ?? "");
if (!pickName || !record.probs || typeof record.probs !== "object") {
return [market, record];
}
const syntheticPick = {
market,
pick: pickName,
probability: this.lookupProbability(record.probs as Record<string, unknown>, pickName),
probability: this.lookupProbability(
record.probs as Record<string, unknown>,
pickName,
),
confidence: Number(record.confidence ?? 0),
calibrated_confidence: Number(record.confidence ?? 0),
raw_confidence: Number(record.confidence ?? 0),
@@ -447,7 +451,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
implied_prob: 0,
play_score: 0,
playable: false,
bet_grade: 'PASS',
bet_grade: "PASS",
stake_units: 0,
decision_reasons: [],
};
@@ -464,7 +468,8 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
{
...record,
confidence_interval: this.asRecord(enriched?.confidence_interval),
confidence_band: this.asRecord(enriched?.confidence_interval).band ?? 'LOW',
confidence_band:
this.asRecord(enriched?.confidence_interval).band ?? "LOW",
},
];
}),
@@ -472,14 +477,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
...response,
match_info: matchInfo as MatchPredictionDto['match_info'],
match_info: matchInfo as MatchPredictionDto["match_info"],
data_quality: {
...dataQuality,
lineup_source: String(dataQuality.lineup_source ?? 'none'),
} as MatchPredictionDto['data_quality'],
lineup_source: String(dataQuality.lineup_source ?? "none"),
} as MatchPredictionDto["data_quality"],
risk: {
...risk,
surprise_type: this.translateReason(String(risk.surprise_type ?? '')),
surprise_type: this.translateReason(String(risk.surprise_type ?? "")),
surprise_reasons: Array.isArray(risk.surprise_reasons)
? risk.surprise_reasons.map((reason) =>
this.translateReason(String(reason)),
@@ -490,13 +495,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
this.translateReason(String(warning)),
)
: [],
} as MatchPredictionDto['risk'],
} as MatchPredictionDto["risk"],
main_pick: mainPick,
value_pick: valuePick,
aggressive_pick: aggressivePick,
supporting_picks: supportingPicks,
bet_summary: betSummary,
bet_advice: betAdvice as MatchPredictionDto['bet_advice'],
bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
market_board: enrichedMarketBoard,
reasoning_factors: reasoningFactors,
};
@@ -507,14 +512,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto['main_pick'] {
if (!pick || typeof pick !== 'object') {
): MatchPredictionDto["main_pick"] {
if (!pick || typeof pick !== "object") {
return null;
}
const record = this.asRecord(pick);
const market = String(record.market ?? '');
const pickName = String(record.pick ?? '');
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability =
this.asNumber(record.probability) ||
@@ -538,7 +543,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
@@ -547,10 +552,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
? [...record.decision_reasons]
: [];
if (!interval.threshold_met) {
nextReasons.push('confidence_interval_too_wide');
nextReasons.push("confidence_interval_too_wide");
}
if (interval.band === 'LOW') {
nextReasons.push('confidence_band_low');
if (interval.band === "LOW") {
nextReasons.push("confidence_band_low");
}
const displayOdds = this.normalizeDisplayOdds(
@@ -559,7 +564,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
return {
...(record as MatchPredictionDto['main_pick']),
...(record as MatchPredictionDto["main_pick"]),
market,
pick: pickName,
probability,
@@ -573,7 +578,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
odds: displayOdds,
edge: this.asNumber(record.edge),
play_score: this.asNumber(record.play_score),
bet_grade: String(record.bet_grade || 'PASS') as 'A' | 'B' | 'C' | 'PASS',
bet_grade: String(record.bet_grade || "PASS") as "A" | "B" | "C" | "PASS",
implied_prob: impliedProb,
ev_edge: evEdge,
playable: Boolean(record.playable) && interval.threshold_met,
@@ -594,10 +599,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
prediction: Record<string, unknown>,
matchContext: MatchContext,
marketBoard: Record<string, unknown>,
): MatchPredictionDto['bet_summary'][number] {
): MatchPredictionDto["bet_summary"][number] {
const record = this.asRecord(item);
const market = String(record.market ?? '');
const pickName = String(record.pick ?? '');
const market = String(record.market ?? "");
const pickName = String(record.pick ?? "");
const probs = this.resolveMarketProbabilities(marketBoard, market);
const probability = this.lookupProbability(probs, pickName);
const calibratedConfidence =
@@ -621,13 +626,13 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
),
riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
lineupSource: String(
this.asRecord(prediction.data_quality).lineup_source ?? 'none',
this.asRecord(prediction.data_quality).lineup_source ?? "none",
),
isTopLeague: matchContext.isTopLeague,
});
return {
...(record as MatchPredictionDto['bet_summary'][number]),
...(record as MatchPredictionDto["bet_summary"][number]),
odds: this.normalizeDisplayOdds(odds, impliedProb),
implied_prob: impliedProb,
ev_edge: evEdge,
@@ -658,12 +663,10 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private translateReason(reason: string): string {
if (!reason) {
return '';
return "";
}
const normalized = reason.startsWith('risk:')
? reason.slice(5)
: reason;
const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;
if (this.reasonTranslations[normalized]) {
return this.reasonTranslations[normalized];
@@ -674,7 +677,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
}
const negativeEdgeMatch = normalized.match(/^negative_model_edge_([+\-]?[\d.]+)$/);
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
@@ -692,47 +697,44 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private classifySignalTier(
record: Record<string, unknown>,
interval: {
band?: 'HIGH' | 'MEDIUM' | 'LOW';
band?: "HIGH" | "MEDIUM" | "LOW";
threshold_met?: boolean;
},
): 'CORE' | 'VALUE' | 'LEAN' | 'LONGSHOT' | 'PASS' {
const playable = Boolean(record.playable) && Boolean(interval.threshold_met);
): "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS" {
const playable =
Boolean(record.playable) && Boolean(interval.threshold_met);
const calibratedConfidence = this.asNumber(record.calibrated_confidence);
const odds = this.asNumber(record.odds);
const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
const playScore = this.asNumber(record.play_score);
const band = String(interval.band ?? 'LOW').toUpperCase();
const band = String(interval.band ?? "LOW").toUpperCase();
if (
playable &&
band === 'HIGH' &&
band === "HIGH" &&
calibratedConfidence >= 72 &&
evEdge >= 0.02 &&
playScore >= 68
) {
return 'CORE';
return "CORE";
}
if (
calibratedConfidence >= 52 &&
odds >= 1.75 &&
evEdge >= 0.04
) {
return playable ? 'VALUE' : 'LONGSHOT';
if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
return playable ? "VALUE" : "LONGSHOT";
}
if (
calibratedConfidence >= 46 &&
(band === 'HIGH' || band === 'MEDIUM' || evEdge > 0)
(band === "HIGH" || band === "MEDIUM" || evEdge > 0)
) {
return 'LEAN';
return "LEAN";
}
if (odds >= 2.2 && calibratedConfidence >= 38) {
return 'LONGSHOT';
return "LONGSHOT";
}
return 'PASS';
return "PASS";
}
private estimateConfidenceInterval(input: {
@@ -755,7 +757,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const secondProb = sortedProbs[1] ?? 0;
const topProb = sortedProbs[0] ?? probability;
const margin = Math.max(0, topProb - secondProb);
const normalizedConfidence = this.normalizePercent(input.calibratedConfidence);
const normalizedConfidence = this.normalizePercent(
input.calibratedConfidence,
);
const baseWidthByMarket: Record<string, number> = {
MS: 0.18,
@@ -767,19 +771,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
};
const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
const lineupPenalty =
input.lineupSource === 'confirmed_live'
input.lineupSource === "confirmed_live"
? -0.015
: input.lineupSource === 'probable_xi'
: input.lineupSource === "probable_xi"
? 0
: 0.02;
const width = this.clamp(
baseWidth
- margin * 0.22
- normalizedConfidence * 0.05
+ (1 - input.dataQualityScore) * 0.09
+ input.riskScore * 0.08
- (input.isTopLeague ? 0.012 : 0)
+ lineupPenalty,
baseWidth -
margin * 0.22 -
normalizedConfidence * 0.05 +
(1 - input.dataQualityScore) * 0.09 +
input.riskScore * 0.08 -
(input.isTopLeague ? 0.012 : 0) +
lineupPenalty,
0.08,
0.34,
);
@@ -795,17 +799,17 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
width <= this.getMaxAllowedWidth(input.market) &&
input.dataQualityScore >= 0.58 &&
input.evEdge >= this.getMinEdge(input.market) &&
(upper - input.impliedProb) >= 0.03;
upper - input.impliedProb >= 0.03;
let band: ConfidenceBand = 'LOW';
let band: ConfidenceBand = "LOW";
if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
band = 'HIGH';
band = "HIGH";
} else if (
input.calibratedConfidence >= 58 &&
width <= 0.18 &&
margin >= 0.035
) {
band = 'MEDIUM';
band = "MEDIUM";
}
return {
@@ -864,7 +868,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
): Record<string, unknown> {
const entry = this.asRecord(marketBoard[market]);
const probs = entry.probs;
return probs && typeof probs === 'object'
return probs && typeof probs === "object"
? (probs as Record<string, unknown>)
: {};
}
@@ -895,7 +899,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private normalizeScore(value: unknown): number {
const numeric = this.asNumber(value);
return numeric > 1 ? this.clamp(numeric / 100, 0, 1) : this.clamp(numeric, 0, 1);
return numeric > 1
? this.clamp(numeric / 100, 0, 1)
: this.clamp(numeric, 0, 1);
}
private normalizePercent(value: number): number {
@@ -903,15 +909,15 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object'
return value && typeof value === "object"
? (value as Record<string, any>)
: {};
}
private asNumber(value: unknown): number {
return typeof value === 'number'
return typeof value === "number"
? value
: typeof value === 'string'
: typeof value === "string"
? Number(value) || 0
: 0;
}
@@ -922,7 +928,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getValueBets(): Promise<ValueBetDto[]> {
const predictions = await this.prisma.prediction.findMany({
where: { match: { status: 'NS' } },
where: { match: { status: "NS" } },
include: { match: { include: { homeTeam: true, awayTeam: true } } },
});
@@ -937,14 +943,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
valueBets.push({
matchId: p.matchId,
matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
betType: (vb.market || vb.betType || '') as string,
prediction: (vb.pick || vb.prediction || '') as string,
confidence: typeof vb.confidence === 'number' ? vb.confidence : 0,
odd: typeof vb.odd === 'number' ? vb.odd : 0,
betType: (vb.market || vb.betType || "") as string,
prediction: (vb.pick || vb.prediction || "") as string,
confidence: typeof vb.confidence === "number" ? vb.confidence : 0,
odd: typeof vb.odd === "number" ? vb.odd : 0,
expectedValue:
typeof vb.edge === 'number'
typeof vb.edge === "number"
? vb.edge
: typeof vb.expectedValue === 'number'
: typeof vb.expectedValue === "number"
? vb.expectedValue
: 0,
});
@@ -959,7 +965,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getSmartCoupon(
matchIds: string[],
strategy: string = 'BALANCED',
strategy: string = "BALANCED",
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<any> {
await this.ensureSmartCouponDataReady(matchIds);
@@ -997,23 +1003,23 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private throwAiError(message: string): never {
if (
message.includes('timed out') ||
message.includes('AI_ENGINE_TIMEOUT') ||
message.includes('AI_ENGINE_504')
message.includes("timed out") ||
message.includes("AI_ENGINE_TIMEOUT") ||
message.includes("AI_ENGINE_504")
) {
throw new HttpException(
'Prediction request timed out',
"Prediction request timed out",
HttpStatus.GATEWAY_TIMEOUT,
);
}
if (message.includes('AI_ENGINE_502')) {
if (message.includes("AI_ENGINE_502")) {
throw new HttpException(
'AI Engine upstream returned 502',
"AI Engine upstream returned 502",
HttpStatus.BAD_GATEWAY,
);
}
throw new HttpException(
'Failed to get prediction from AI Engine',
"Failed to get prediction from AI Engine",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
@@ -1066,12 +1072,12 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
const cached = prediction.predictionJson as Record<string, unknown>;
const modelVersion = cached['model_version'];
if (typeof modelVersion !== 'string') {
const modelVersion = cached["model_version"];
if (typeof modelVersion !== "string") {
return null;
}
if (!modelVersion.startsWith('v25')) {
if (!modelVersion.startsWith("v25")) {
return null;
}
@@ -1082,7 +1088,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
if (uniqueMatchIds.length === 0) {
throw new HttpException(
'No matchIds provided for smart coupon generation',
"No matchIds provided for smart coupon generation",
HttpStatus.BAD_REQUEST,
);
}
@@ -1122,7 +1128,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const hasLiveOdds =
!!liveMatch?.odds &&
typeof liveMatch.odds === 'object' &&
typeof liveMatch.odds === "object" &&
!Array.isArray(liveMatch.odds) &&
Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
const matchExists = !!liveMatch?.id || !!persistedMatch?.id;
@@ -1146,9 +1152,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
const isFinished =
hasScores ||
state === 'MS' ||
state === 'postGame' ||
['Finished', 'Played', 'FT', 'AET', 'PEN', 'Ended'].includes(
state === "MS" ||
state === "postGame" ||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string,
);
@@ -1,18 +1,18 @@
/* eslint-disable @typescript-eslint/unbound-method */
import axios from 'axios';
import { PredictionJobType } from './predictions.types';
import { PredictionsProcessor } from './predictions.processor';
import axios from "axios";
import { PredictionJobType } from "./predictions.types";
import { PredictionsProcessor } from "./predictions.processor";
jest.mock('axios');
jest.mock("axios");
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('PredictionsProcessor', () => {
describe("PredictionsProcessor", () => {
let processor: PredictionsProcessor;
beforeEach(() => {
jest.clearAllMocks();
process.env.AI_ENGINE_URL = 'http://unit-ai:8000';
process.env.AI_ENGINE_URL = "http://unit-ai:8000";
processor = new PredictionsProcessor();
});
@@ -20,34 +20,34 @@ describe('PredictionsProcessor', () => {
delete process.env.AI_ENGINE_URL;
});
it('posts to analyze endpoint for predict-match jobs', async () => {
it("posts to analyze endpoint for predict-match jobs", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
const job = {
id: 'j1',
id: "j1",
name: PredictionJobType.PREDICT_MATCH,
data: { matchId: 'match-123' },
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',
"http://unit-ai:8000/v20plus/analyze/match-123",
{},
{ timeout: 30000 },
);
});
it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => {
it("posts mapped payload to coupon endpoint for smart-coupon jobs", async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
const job = {
id: 'j2',
id: "j2",
name: PredictionJobType.SMART_COUPON,
data: {
matchIds: ['m1', 'm2'],
strategy: 'BALANCED',
matchIds: ["m1", "m2"],
strategy: "BALANCED",
options: { maxMatches: 4, minConfidence: 65 },
},
} as any;
@@ -56,10 +56,10 @@ describe('PredictionsProcessor', () => {
expect(result).toEqual({ bets: [] });
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://unit-ai:8000/v20plus/coupon',
"http://unit-ai:8000/v20plus/coupon",
{
match_ids: ['m1', 'm2'],
strategy: 'BALANCED',
match_ids: ["m1", "m2"],
strategy: "BALANCED",
max_matches: 4,
min_confidence: 65,
},
@@ -67,15 +67,15 @@ describe('PredictionsProcessor', () => {
);
});
it('throws for unknown job type', async () => {
it("throws for unknown job type", async () => {
const job = {
id: 'j3',
name: 'unknown-job',
id: "j3",
name: "unknown-job",
data: {},
} as any;
await expect(processor.process(job)).rejects.toThrow(
'Unknown job type: unknown-job',
"Unknown job type: unknown-job",
);
});
});
@@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
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';
} from "./predictions.types";
import axios from "axios";
/**
* Predictions Processor
@@ -22,7 +22,7 @@ export class PredictionsProcessor extends WorkerHost {
constructor() {
super();
// Default to container service URL
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000";
}
async process(job: Job<any, any, string>): Promise<any> {
@@ -56,7 +56,7 @@ export class PredictionsProcessor extends WorkerHost {
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchId, 'predict');
throw this.mapAxiosError(error, matchId, "predict");
}
}
@@ -81,14 +81,14 @@ export class PredictionsProcessor extends WorkerHost {
);
return response.data;
} catch (error) {
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon');
throw this.mapAxiosError(error, matchIds.join(","), "smart-coupon");
}
}
private mapAxiosError(
error: unknown,
identifier: string,
flow: 'predict' | 'smart-coupon',
flow: "predict" | "smart-coupon",
): Error {
if (!axios.isAxiosError(error)) {
return error instanceof Error
@@ -98,7 +98,7 @@ export class PredictionsProcessor extends WorkerHost {
const status = error.response?.status;
const detail = error.response?.data?.detail || error.message;
const code = error.code || '';
const code = error.code || "";
if (status === 502) {
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
@@ -110,13 +110,13 @@ export class PredictionsProcessor extends WorkerHost {
return new Error(`AI_ENGINE_504|${flow}|${detail}`);
}
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') {
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}`,
`AI Engine error (${flow}:${identifier}) [${status ?? "N/A"}]: ${detail}`,
);
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
}
@@ -1,12 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { Injectable, Logger } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import {
PREDICTIONS_QUEUE,
PredictionJobType,
PredictMatchJobData,
SmartCouponJobData,
} from './predictions.types';
} from "./predictions.types";
@Injectable()
export class PredictionsQueue {
@@ -3,11 +3,11 @@
* Senior Level Strict Typing
*/
export const PREDICTIONS_QUEUE = 'predictions-queue';
export const PREDICTIONS_QUEUE = "predictions-queue";
export enum PredictionJobType {
PREDICT_MATCH = 'predict-match',
SMART_COUPON = 'smart-coupon',
PREDICT_MATCH = "predict-match",
SMART_COUPON = "smart-coupon",
}
export interface PredictMatchJobData {
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../database/prisma.service";
@Injectable()
export class AiFeatureStoreService {
@@ -16,10 +16,10 @@ export class AiFeatureStoreService {
where: { id: matchId },
include: {
homeTeam: {
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
include: { homeMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
},
awayTeam: {
include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
include: { awayMatches: { take: 5, orderBy: { mstUtc: "desc" } } },
},
},
});