333 lines
9.3 KiB
TypeScript
Executable File
333 lines
9.3 KiB
TypeScript
Executable File
import { Injectable, Logger } from "@nestjs/common";
|
||
import { ConfigService } from "@nestjs/config";
|
||
import {
|
||
AiEngineClient,
|
||
AiEngineRequestError,
|
||
} from "../common/utils/ai-engine-client";
|
||
|
||
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;
|
||
private readonly aiEngineClient: AiEngineClient;
|
||
|
||
constructor(
|
||
private readonly configService: ConfigService,
|
||
) {
|
||
this.pythonEngineUrl =
|
||
this.configService.get("AI_ENGINE_URL") || "http://127.0.0.1:8000";
|
||
this.aiEngineClient = new AiEngineClient({
|
||
baseUrl: this.pythonEngineUrl,
|
||
logger: this.logger,
|
||
serviceName: AiService.name,
|
||
timeoutMs: 30000,
|
||
maxRetries: 2,
|
||
retryDelayMs: 500,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 V28 Pro Max Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
|
||
);
|
||
|
||
const response = await this.aiEngineClient.post(
|
||
`/v20plus/analyze/${matchId}`,
|
||
{},
|
||
);
|
||
|
||
if (response.data) {
|
||
return this.mapPythonResponse(response.data, matchDetails);
|
||
}
|
||
|
||
return null;
|
||
} catch (error: unknown) {
|
||
const message =
|
||
error instanceof AiEngineRequestError
|
||
? error.message
|
||
: error instanceof Error
|
||
? error.message
|
||
: "Unknown AI engine error";
|
||
this.logger.warn(`Python Engine 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: "v28-pro-max",
|
||
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: "v28-pro-max",
|
||
expectedGoals: pyData.score_prediction?.xg_total || 0,
|
||
keyInsights: [
|
||
`Model: v28-pro-max`,
|
||
`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 this.aiEngineClient.get<{ status?: string }>(
|
||
"/health",
|
||
{
|
||
timeout: 5000,
|
||
retryCount: 0,
|
||
},
|
||
);
|
||
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: "v28-pro-max",
|
||
expectedGoals: 0,
|
||
keyInsights: [],
|
||
};
|
||
}
|
||
}
|