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
+248
View File
@@ -0,0 +1,248 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { GeminiService } from '../../gemini/gemini.service';
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW';
export type BetGrade = 'A' | 'B' | 'C' | 'PASS';
export interface PredictionPickRow {
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
raw_confidence: number;
calibrated_confidence: number;
min_required_confidence: number;
edge: number;
play_score: number;
playable: boolean;
bet_grade: BetGrade;
stake_units: number;
decision_reasons: string[];
}
export interface PredictionBetSummaryRow {
market: string;
pick: string;
raw_confidence: number;
calibrated_confidence: number;
bet_grade: BetGrade;
playable: boolean;
stake_units: number;
play_score: number;
reasons: string[];
}
export interface SingleMatchPredictionPackage {
model_version: string;
match_info: {
match_id: string;
match_name: string;
home_team: string;
away_team: string;
league: string;
match_date_ms: number;
};
data_quality: {
label: PredictionDataQuality;
score: number;
flags: string[];
home_lineup_count: number;
away_lineup_count: number;
};
risk: {
level: PredictionRiskLevel;
score: number;
is_surprise_risk: boolean;
surprise_type: string | null;
warnings: string[];
};
engine_breakdown: {
team: number;
player: number;
odds: number;
referee: number;
};
main_pick: PredictionPickRow | null;
value_pick: PredictionPickRow | null;
bet_advice: {
playable: boolean;
suggested_stake_units: number;
reason: string;
};
bet_summary: PredictionBetSummaryRow[];
supporting_picks: PredictionPickRow[];
aggressive_pick: {
market: string;
pick: string;
probability: number;
confidence: number;
odds: number | null;
} | null;
scenario_top5: Array<{
score: string;
prob: number;
[key: string]: unknown;
}>;
score_prediction: {
ft: string;
ht: string;
xg_home: number;
xg_away: number;
xg_total: number;
};
market_board: Record<string, unknown>;
reasoning_factors: string[];
ai_commentary?: string | null;
}
export interface SmartCouponResult {
strategy: string;
generated_at: string;
match_count: number;
bets: Array<{
match_id: string;
match_name: string;
market: string;
pick: string;
probability: number;
confidence: number;
odds: number;
risk_level: PredictionRiskLevel;
data_quality: PredictionDataQuality;
}>;
total_odds: number;
expected_win_rate: number;
rejected_matches: Array<{
match_id: string;
reason: string;
threshold?: number;
}>;
}
@Injectable()
export class SmartCouponService {
private readonly logger = new Logger(SmartCouponService.name);
private readonly aiEngineUrl: string;
constructor(private readonly geminiService: GeminiService) {
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
}
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
let prediction: SingleMatchPredictionPackage;
try {
const response = await axios.post<SingleMatchPredictionPackage>(
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
);
prediction = response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
throw new HttpException(
`AI analyze failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
'AI analyze failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
// Generate AI commentary (non-blocking — fail-safe)
prediction.ai_commentary = await this.generateMatchCommentary(prediction);
return prediction;
}
private async generateMatchCommentary(
prediction: SingleMatchPredictionPackage,
): Promise<string | null> {
if (!this.geminiService.isAvailable()) {
return null;
}
try {
const result = await this.geminiService.generateText(
JSON.stringify(prediction, null, 2),
{
model: 'gemini-2.0-flash',
temperature: 0.7,
maxTokens: 600,
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
},
);
return result.text || null;
} catch (error) {
this.logger.warn('AI commentary generation failed, skipping', error);
return null;
}
}
async generateDailyBankoCoupon(
matchIds: string[],
): Promise<SmartCouponResult | null> {
if (matchIds.length === 0) {
return null;
}
return this.getSmartCoupon(matchIds, 'SAFE', {
maxMatches: 2,
minConfidence: 78,
});
}
async getSmartCoupon(
matchIds: string[],
strategy:
| 'SAFE'
| 'BALANCED'
| 'AGGRESSIVE'
| 'VALUE'
| 'MIRACLE' = 'BALANCED',
options: { maxMatches?: number; minConfidence?: number } = {},
): Promise<SmartCouponResult> {
try {
const response = await axios.post<SmartCouponResult>(
`${this.aiEngineUrl}/v20plus/coupon`,
{
match_ids: matchIds,
strategy,
max_matches: options.maxMatches,
min_confidence: options.minConfidence,
},
);
return response.data;
} catch (error) {
this.logger.error('Failed to generate smart coupon', error);
if (axios.isAxiosError(error)) {
const detail = error.response?.data?.detail || error.message;
throw new HttpException(
`Coupon generation failed: ${detail}`,
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}
throw new HttpException(
'Coupon generation failed',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
}
const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz.
Kurallar:
- Max 3-4 kısa paragraf, gereksiz uzatma
- Playable olan marketleri ve nedenlerini açıkla
- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz)
- Tüm edge'ler negatifse "trap maç" olarak uyar
- xG ve skor senaryolarına göre strateji öner
- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama
- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi)
- "Ben olsam..." formatında kişisel tavsiye ver
- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰
- Markdown formatı KULLANMA, düz metin yaz
- Bahis terminolojisi kullan: edge, value, implied odds, xG`;
+189
View File
@@ -0,0 +1,189 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../database/prisma.service';
import { User, UserCoupon, Match } from '@prisma/client';
export class CreateCouponDto {
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
items: {
matchId: string;
selection: string; // 'MS 1', '2.5 UST'
odd: number;
}[];
isPublic?: boolean;
}
export interface UserStatsDto {
totalCoupons: number;
wonCoupons: number;
winRate: number; // Percentage
totalInvested: number; // Unit based (1 unit per coupon)
totalReturn: number;
roi: number; // Return on Investment %
}
@Injectable()
export class UserCouponService {
private readonly logger = new Logger(UserCouponService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Kullanıcı için yeni bir kupon oluşturur ve kaydeder.
*/
async createCoupon(user: User, dto: CreateCouponDto): Promise<UserCoupon> {
const totalOdds = dto.items.reduce((acc, item) => acc * item.odd, 1);
const coupon = await this.prisma.userCoupon.create({
data: {
userId: user.id,
strategy: dto.strategy,
totalOdds: parseFloat(totalOdds.toFixed(2)),
isPublic: dto.isPublic || false,
status: 'PENDING',
couponItems: {
create: dto.items.map((item) => ({
matchId: item.matchId,
selection: item.selection,
oddAtTime: item.odd,
})),
},
},
include: {
couponItems: true,
},
});
this.logger.log(
`Coupon created for user ${user.email} with odds ${totalOdds}`,
);
return coupon;
}
/**
* Bekleyen kuponların sonuçlarını kontrol eder ve günceller.
* Bu metod bir Cron Job tarafından periyodik olarak çağrılmalıdır.
*/
async updatePendingCoupons(): Promise<void> {
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
const pendingCoupons = await this.prisma.userCoupon.findMany({
where: { status: 'PENDING' },
include: {
couponItems: {
include: { match: true },
},
},
});
for (const coupon of pendingCoupons) {
let isCouponWon = true;
let isCouponLost = false;
let allMatchesFinished = true;
for (const item of coupon.couponItems) {
if (item.match.status !== 'FT') {
allMatchesFinished = false;
break; // Henüz bitmemiş maç var, kuponu güncelleme
}
const isItemWon = this.checkSelection(item.selection, item.match);
// Sonucu item bazında güncelle
if (item.isCorrect !== isItemWon) {
await this.prisma.userCouponItem.update({
where: { id: item.id },
data: { isCorrect: isItemWon },
});
}
if (!isItemWon) {
isCouponLost = true;
isCouponWon = false;
}
}
if (isCouponLost) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'LOST' },
});
} else if (allMatchesFinished && isCouponWon) {
await this.prisma.userCoupon.update({
where: { id: coupon.id },
data: { status: 'WON' },
});
}
}
}
/**
* Basit bir kural seti ile bahsin tutup tutmadığını kontrol eder.
* Gerçek dünyada bu daha karmaşık bir 'BetSettlementService' olmalıdır.
*/
private checkSelection(selection: string, match: Match): boolean {
const home = match.scoreHome ?? 0;
const away = match.scoreAway ?? 0;
const total = home + away;
switch (selection) {
case 'MS 1':
return home > away;
case 'MS X':
return home === away;
case 'MS 2':
return away > home;
case '1.5 UST':
return total > 1.5;
case '2.5 UST':
return total > 2.5;
case '3.5 UST':
return total > 3.5;
case '2.5 ALT':
return total < 2.5;
case 'KG VAR':
return home > 0 && away > 0;
case 'KG YOK':
return home === 0 || away === 0;
default:
return false; // Bilinmeyen market
}
}
/**
* Kullanıcının bahis performans istatistiklerini getirir.
*/
async getUserStatistics(userId: string): Promise<UserStatsDto> {
const coupons = await this.prisma.userCoupon.findMany({
where: {
userId,
status: { in: ['WON', 'LOST'] },
},
});
const totalCoupons = coupons.length;
if (totalCoupons === 0) {
return {
totalCoupons: 0,
wonCoupons: 0,
winRate: 0,
totalInvested: 0,
totalReturn: 0,
roi: 0,
};
}
const wonCoupons = coupons.filter((c) => c.status === 'WON');
const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
const winRate = (wonCoupons.length / totalCoupons) * 100;
const roi = ((totalReturn - totalInvested) / totalInvested) * 100;
return {
totalCoupons,
wonCoupons: wonCoupons.length,
winRate: parseFloat(winRate.toFixed(2)),
totalInvested,
totalReturn: parseFloat(totalReturn.toFixed(2)),
roi: parseFloat(roi.toFixed(2)),
};
}
}