This commit is contained in:
Executable
+319
@@ -0,0 +1,319 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface AIPredictionResult {
|
||||
matchId: string;
|
||||
matchName: string;
|
||||
predictions: {
|
||||
betType: string;
|
||||
prediction: string;
|
||||
confidence: number;
|
||||
probabilities?: Record<string, number>;
|
||||
reasoning?: string;
|
||||
odd?: number;
|
||||
valueBet?: { isValue: boolean; edge: number };
|
||||
}[];
|
||||
recommendedBets: string[];
|
||||
homeAnalysis?: {
|
||||
teamName: string;
|
||||
formText: string;
|
||||
goalsAvg: number;
|
||||
formRating: string;
|
||||
squadStrength?: number;
|
||||
};
|
||||
awayAnalysis?: {
|
||||
teamName: string;
|
||||
formText: string;
|
||||
goalsAvg: number;
|
||||
formRating: string;
|
||||
squadStrength?: number;
|
||||
};
|
||||
expertComment: string;
|
||||
modelVersion: string;
|
||||
confidenceScore: number;
|
||||
expectedGoals?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private readonly pythonEngineUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.pythonEngineUrl =
|
||||
this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Python match analysis engine and map the result to stable frontend contract.
|
||||
*/
|
||||
async callPythonEngine(
|
||||
matchDetails: any,
|
||||
_odds: any[],
|
||||
_lineups: { home: any[]; away: any[] },
|
||||
_substitutes: { home: any[]; away: any[] } | null,
|
||||
_stats: any,
|
||||
_eventData: any[],
|
||||
): Promise<AIPredictionResult | null> {
|
||||
try {
|
||||
const matchId = String(matchDetails?.matchId || '').trim();
|
||||
if (!matchId) {
|
||||
this.logger.warn('Skipping AI call: missing matchId');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Calling Python V25 Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
|
||||
);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.pythonEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
{},
|
||||
{
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data) {
|
||||
return this.mapPythonResponse(response.data, matchDetails);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Python Engine error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Python response to our interface
|
||||
*/
|
||||
private mapPythonResponse(data: any, matchDetails: any): AIPredictionResult {
|
||||
const picks = Array.isArray(data?.bet_summary) ? data.bet_summary : [];
|
||||
const recommendedBets = picks
|
||||
.filter((p: any) => p?.playable)
|
||||
.map((p: any) => `${p.market}: ${p.pick}`);
|
||||
|
||||
const mappedPredictions = picks.map((p: any) => ({
|
||||
betType: String(p.market || ''),
|
||||
prediction: String(p.pick || ''),
|
||||
confidence: Number(p.calibrated_confidence ?? p.confidence ?? 0),
|
||||
probabilities: {},
|
||||
reasoning: Array.isArray(p.reasons)
|
||||
? p.reasons.join(' | ')
|
||||
: Array.isArray(p.decision_reasons)
|
||||
? p.decision_reasons.join(' | ')
|
||||
: '',
|
||||
odd: typeof p.odds === 'number' ? p.odds : undefined,
|
||||
valueBet:
|
||||
typeof p.edge === 'number'
|
||||
? {
|
||||
isValue: p.edge > 0,
|
||||
edge: p.edge,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const matchInfo = data?.match_info || {};
|
||||
const confidenceScore = Number(
|
||||
data?.main_pick?.calibrated_confidence ??
|
||||
data?.main_pick?.confidence ??
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
matchId: matchDetails.matchId || matchInfo.match_id || data.match_id,
|
||||
matchName: `${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
|
||||
predictions: mappedPredictions,
|
||||
recommendedBets:
|
||||
data.recommended_bets && Array.isArray(data.recommended_bets)
|
||||
? data.recommended_bets
|
||||
: recommendedBets,
|
||||
homeAnalysis: undefined,
|
||||
awayAnalysis: undefined,
|
||||
expertComment: data.ai_commentary || data.expert_comment || '',
|
||||
modelVersion: data.model_version || 'v25.main',
|
||||
confidenceScore:
|
||||
confidenceScore > 1 ? confidenceScore : confidenceScore * 100,
|
||||
expectedGoals: data?.score_prediction?.xg_total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapped prediction response from the AI package.
|
||||
*/
|
||||
getPredictionForMatch(analysisData: any): any {
|
||||
const pyData = analysisData.liveMatchData?.pythonEnginePrediction;
|
||||
|
||||
if (!pyData || !Array.isArray(pyData.bet_summary)) {
|
||||
return this.getEmptyPrediction();
|
||||
}
|
||||
|
||||
const allPredictions = pyData.bet_summary.map((p: any) => ({
|
||||
betType: p.market,
|
||||
prediction: p.pick,
|
||||
confidence: p.calibrated_confidence ?? p.confidence ?? 0,
|
||||
probabilities: {},
|
||||
reasoning: Array.isArray(p.reasons) ? p.reasons.join(' | ') : '',
|
||||
odd: p.odds || 0,
|
||||
valueBet: {
|
||||
is_value: typeof p.edge === 'number' ? p.edge > 0 : false,
|
||||
edge: p.edge || 0,
|
||||
},
|
||||
}));
|
||||
const firstPick = allPredictions[0];
|
||||
|
||||
return {
|
||||
predictions: allPredictions,
|
||||
recommendedBets: pyData.recommended_bets || [],
|
||||
valueBets: allPredictions.filter((p: any) => p.valueBet?.is_value),
|
||||
homeAnalysis: null,
|
||||
awayAnalysis: null,
|
||||
expertComment: pyData.ai_commentary || '',
|
||||
winnerPrediction: firstPick?.prediction || 'N/A',
|
||||
scorePrediction: pyData.score_prediction?.ft || '-',
|
||||
confidenceScore:
|
||||
typeof firstPick?.confidence === 'number' ? firstPick.confidence : 0,
|
||||
modelVersion: pyData.model_version || 'v25.main',
|
||||
expectedGoals: pyData.score_prediction?.xg_total || 0,
|
||||
keyInsights: [
|
||||
`Model: ${pyData.model_version || 'v25.main'}`,
|
||||
`Risk: ${pyData.risk?.level || 'N/A'} (${pyData.risk?.score ?? 0})`,
|
||||
`Data Quality: ${pyData.data_quality?.label || 'N/A'}`,
|
||||
`xG Beklentisi: ${
|
||||
typeof pyData.score_prediction?.xg_total === 'number'
|
||||
? pyData.score_prediction.xg_total.toFixed(2)
|
||||
: 'N/A'
|
||||
}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate analysis strategy (replaces Gemini)
|
||||
*/
|
||||
getAnalysisStrategy(matchDNA: any): { analysisTactics: any[] } {
|
||||
const tactics: any[] = [];
|
||||
const odds = matchDNA?.odds || [];
|
||||
|
||||
// MS 1 oranını bul
|
||||
const ms1 = odds.find(
|
||||
(o: any) =>
|
||||
o.category?.toLowerCase().includes('maç sonucu') && o.selection === '1',
|
||||
);
|
||||
|
||||
// KG Var oranını bul
|
||||
const kgVar = odds.find(
|
||||
(o: any) =>
|
||||
o.category?.toLowerCase().includes('karşılıklı gol') &&
|
||||
o.selection?.toLowerCase() === 'var',
|
||||
);
|
||||
|
||||
// Alt 2.5 oranını bul
|
||||
const alt25 = odds.find(
|
||||
(o: any) =>
|
||||
o.category?.toLowerCase().includes('alt/üst') &&
|
||||
o.selection?.toLowerCase() === 'alt',
|
||||
);
|
||||
|
||||
// Tactic 1: Benzer MS oranları
|
||||
if (ms1?.odd_value) {
|
||||
tactics.push({
|
||||
tacticName: 'Benzer Maç Sonucu Oranları',
|
||||
description:
|
||||
'Ev sahibi galibiyeti için benzer oran aralığındaki maçlar',
|
||||
odds: [
|
||||
{
|
||||
categoryName: 'Maç Sonucu',
|
||||
selectionName: '1',
|
||||
value: parseFloat(ms1.odd_value),
|
||||
tolerance: 0.3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Tactic 2: Benzer KG + AU oranları
|
||||
if (kgVar?.odd_value && alt25?.odd_value) {
|
||||
tactics.push({
|
||||
tacticName: 'Benzer Gol Beklentisi',
|
||||
description: 'Karşılıklı gol ve toplam gol benzerliği',
|
||||
odds: [
|
||||
{
|
||||
categoryName: 'Karşılıklı Gol',
|
||||
selectionName: 'Var',
|
||||
value: parseFloat(kgVar.odd_value),
|
||||
tolerance: 0.4,
|
||||
},
|
||||
{
|
||||
categoryName: '2,5 Alt/Üst',
|
||||
selectionName: 'Alt',
|
||||
value: parseFloat(alt25.odd_value),
|
||||
tolerance: 0.3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Tactic 3: Favori analizi
|
||||
if (ms1?.odd_value && parseFloat(ms1.odd_value) < 1.8) {
|
||||
tactics.push({
|
||||
tacticName: 'Favori Takım Analizi',
|
||||
description: 'Benzer şekilde favori olan ev sahibi takımların maçları',
|
||||
odds: [
|
||||
{
|
||||
categoryName: 'Maç Sonucu',
|
||||
selectionName: '1',
|
||||
value: parseFloat(ms1.odd_value),
|
||||
tolerance: 0.2,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return { analysisTactics: tactics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Python engine health
|
||||
*/
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${this.pythonEngineUrl}/health`, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
);
|
||||
return response.data?.status === 'healthy';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty prediction fallback
|
||||
*/
|
||||
private getEmptyPrediction() {
|
||||
return {
|
||||
predictions: [],
|
||||
recommendedBets: [],
|
||||
valueBets: [],
|
||||
homeAnalysis: null,
|
||||
awayAnalysis: null,
|
||||
expertComment: 'Analiz verisi alınamadı (Python Servis Hatası).',
|
||||
winnerPrediction: 'N/A',
|
||||
scorePrediction: '-',
|
||||
confidenceScore: 0,
|
||||
modelVersion: 'v25.main',
|
||||
expectedGoals: 0,
|
||||
keyInsights: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user