main
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s

This commit is contained in:
2026-05-04 18:00:40 +03:00
parent 145a8b336b
commit 27e96da31d
22 changed files with 571 additions and 169 deletions
+152 -62
View File
@@ -21,6 +21,10 @@ import {
} from "./dto";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import {
isMatchCompleted,
isMatchLive,
} from "../../common/utils/match-status.util";
import * as fs from "node:fs";
import * as path from "node:path";
import {
@@ -49,7 +53,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
private queueEvents: QueueEvents | null = null;
private readonly aiEngineUrl: string;
private readonly aiEngineClient: AiEngineClient;
private readonly topLeagueIds = new Set<string>();
private readonly qualifiedLeagueIds = 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ş",
@@ -137,7 +141,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
maxRetries: 2,
retryDelayMs: 750,
});
this.topLeagueIds = this.loadTopLeagueIds();
this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
}
onModuleInit() {
@@ -155,6 +159,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
}
private predictionMemCache = new Map<
string,
{ timestamp: number; payload: MatchPredictionDto }
>();
async onModuleDestroy() {
if (this.queueEvents) {
await this.queueEvents.close();
@@ -177,8 +186,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
status: response.data?.status || "healthy",
modelLoaded: response.data?.model_loaded ?? true,
predictionServiceReady:
response.data?.prediction_service_ready ?? true,
predictionServiceReady: response.data?.prediction_service_ready ?? true,
aiEngineReachable: true,
circuitState: circuit.state,
consecutiveFailures: circuit.consecutiveFailures,
@@ -330,33 +338,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
match_date_ms: Number(p.match.mstUtc) * 1000,
league: p.match.league?.name || "",
league_id: p.match.leagueId,
is_top_league: this.topLeagueIds.has(p.match.leagueId ?? ""),
is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
},
} as unknown as MatchPredictionDto;
}),
};
}
private loadTopLeagueIds(): Set<string> {
private loadQualifiedLeagueIds(): Set<string> {
try {
const topLeaguesPath = path.join(process.cwd(), "top_leagues.json");
if (!fs.existsSync(topLeaguesPath)) {
const filePath = path.join(process.cwd(), "qualified_leagues.json");
if (!fs.existsSync(filePath)) {
this.logger.warn(
"qualified_leagues.json not found — all leagues allowed",
);
return new Set<string>();
}
const raw = JSON.parse(fs.readFileSync(topLeaguesPath, "utf8"));
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!Array.isArray(raw)) {
return new Set<string>();
}
return new Set(
const ids = new Set(
raw
.map((value) => String(value).trim())
.filter((value) => value.length > 0),
);
this.logger.log(`Loaded ${ids.size} qualified league IDs`);
return ids;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to load top_leagues.json: ${message}`);
this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
return new Set<string>();
}
}
@@ -370,7 +383,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
if (match) {
return {
leagueId: match.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(match.leagueId ?? ""),
isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
};
}
@@ -381,7 +394,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return {
leagueId: liveMatch?.leagueId ?? null,
isTopLeague: this.topLeagueIds.has(liveMatch?.leagueId ?? ""),
isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
};
}
@@ -731,20 +744,20 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
return this.reasonTranslations[normalized];
}
const evMatch = normalized.match(/^ev_edge_([+\-][\d.]+%)_grade_(\w)$/);
const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
if (evMatch) {
return `Beklenen avantaj ${evMatch[1]} (Not ${evMatch[2]})`;
}
const negativeEdgeMatch = normalized.match(
/^negative_model_edge_([+\-]?[\d.]+)$/,
/^negative_model_edge_([-+]?[\d.]+)$/,
);
if (negativeEdgeMatch) {
return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
}
const edgeThresholdMatch = normalized.match(
/^below_market_edge_threshold_([+\-]?[\d.]+)$/,
/^below_market_edge_threshold_([-+]?[\d.]+)$/,
);
if (edgeThresholdMatch) {
return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
@@ -1071,10 +1084,11 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
// Direct HTTP mode
try {
const response = await this.aiEngineClient.post(
"/smart-coupon",
{ match_ids: matchIds, strategy, ...options },
);
const response = await this.aiEngineClient.post("/smart-coupon", {
match_ids: matchIds,
strategy,
...options,
});
return response.data;
} catch (error: unknown) {
const message =
@@ -1130,8 +1144,26 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
this.predictionMemCache.set(matchId, {
timestamp: Date.now(),
payload: prediction,
});
if (this.predictionMemCache.size > 500) {
const firstKey = this.predictionMemCache.keys().next().value;
if (firstKey) this.predictionMemCache.delete(firstKey);
}
const payload = prediction as unknown as Prisma.InputJsonObject;
try {
const existsInMatch = await this.prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (!existsInMatch) {
return;
}
await this.prisma.prediction.upsert({
where: { matchId },
update: {
@@ -1151,6 +1183,16 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
async getCachedPrediction(
matchId: string,
): Promise<MatchPredictionDto | null> {
const memCached = this.predictionMemCache.get(matchId);
if (memCached) {
if (Date.now() - memCached.timestamp < 10 * 60 * 1000) {
// 10 mins TTL
return memCached.payload;
} else {
this.predictionMemCache.delete(matchId);
}
}
const prediction = await this.prisma.prediction.findUnique({
where: { matchId },
});
@@ -1216,32 +1258,38 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
}
private async ensurePredictionDataReady(matchId: string): Promise<void> {
const [liveMatch, persistedMatch, oddCategoryCount] = await Promise.all([
this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
id: true,
odds: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
}),
this.prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
},
}),
this.prisma.oddCategory.count({
where: { matchId },
}),
]);
const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
await Promise.all([
this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: {
id: true,
odds: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
leagueId: true,
},
}),
this.prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
state: true,
status: true,
scoreHome: true,
scoreAway: true,
leagueId: true,
},
}),
this.prisma.oddCategory.count({
where: { matchId },
}),
this.prisma.matchPlayerParticipation.count({
where: { matchId },
}),
]);
const hasLiveOdds =
!!liveMatch?.odds &&
@@ -1257,27 +1305,68 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
}
// League qualification gate: reject predictions for leagues without
// sufficient historical training data (odds + lineups + stats)
const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
if (
this.qualifiedLeagueIds.size > 0 &&
(!leagueId || !this.qualifiedLeagueIds.has(leagueId))
) {
throw new HttpException(
`Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
const state = liveMatch?.state || persistedMatch?.state;
const status = liveMatch?.status || persistedMatch?.status;
const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;
const hasScores =
scoreHome !== null &&
scoreHome !== undefined &&
scoreAway !== null &&
scoreAway !== undefined;
const isFinished =
hasScores ||
state === "MS" ||
state === "postGame" ||
["Finished", "Played", "FT", "AET", "PEN", "Ended"].includes(
status as string,
);
const isFinished = isMatchCompleted({
state: state ?? null,
status: status ?? null,
scoreHome,
scoreAway,
});
const isLive = isMatchLive({
state: state ?? null,
status: status ?? null,
});
const hasOdds = hasLiveOdds || oddCategoryCount > 0;
if (hasOdds || isFinished) {
if (hasOdds || isFinished || isLive) {
// ── Lineup guard: fetch lineups if missing before analysis ──
// A proper football lineup has at least 11 starting players (22 total
// with subs). If we have fewer than 11 participation records, the
// lineup data is likely missing — attempt to fetch it from source.
if (lineupCount < 11) {
this.logger.log(
`[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
);
try {
const refreshResult = await this.feederService.refreshMatch(
matchId,
"lineups",
);
if (refreshResult.success) {
this.logger.log(
`[${matchId}] ✅ Lineups fetched successfully before analysis`,
);
} else {
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
);
}
}
return;
}
@@ -1315,7 +1404,9 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Prediction run audit skipped for ${matchId}: ${message}`);
this.logger.warn(
`Prediction run audit skipped for ${matchId}: ${message}`,
);
}
}
@@ -1397,8 +1488,7 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
: null,
bet_advice: {
playable: payload.bet_advice?.playable ?? false,
suggested_stake_units:
payload.bet_advice?.suggested_stake_units ?? 0,
suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
reason: payload.bet_advice?.reason ?? null,
},
top_summary: topSummary,