import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; import { GeminiService } from "../../gemini/gemini.service"; import { AiEngineClient, AiEngineRequestError, } from "../../../common/utils/ai-engine-client"; import { FrequencyEngineService, type MatchCandidate, type FrequencySignal, } from "./frequency-engine.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; 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; private readonly aiEngineClient: AiEngineClient; constructor( private readonly geminiService: GeminiService, private readonly frequencyEngine: FrequencyEngineService, ) { this.aiEngineUrl = process.env.AI_ENGINE_URL || "http://ai-engine:8000"; this.aiEngineClient = new AiEngineClient({ baseUrl: this.aiEngineUrl, logger: this.logger, serviceName: SmartCouponService.name, timeoutMs: 60000, maxRetries: 2, retryDelayMs: 750, }); } async analyzeMatch(matchId: string): Promise { let prediction: SingleMatchPredictionPackage; try { const response = await this.aiEngineClient.post( `/v20plus/analyze/${matchId}`, ); prediction = response.data; } catch (error: unknown) { if (error instanceof AiEngineRequestError) { const detail = typeof error.detail === "string" ? error.detail : error.message; throw new HttpException( `AI analyze failed: ${detail}`, error.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 { 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 { 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 { try { const response = await this.aiEngineClient.post( "/v20plus/coupon", { match_ids: matchIds, strategy, max_matches: options.maxMatches, min_confidence: options.minConfidence, }, ); return response.data; } catch (error: unknown) { this.logger.error("Failed to generate smart coupon", error); if (error instanceof AiEngineRequestError) { const detail = typeof error.detail === "string" ? error.detail : error.message; throw new HttpException( `Coupon generation failed: ${detail}`, error.status || HttpStatus.SERVICE_UNAVAILABLE, ); } throw new HttpException( "Coupon generation failed", HttpStatus.SERVICE_UNAVAILABLE, ); } } // ───────────────────────────────────────────────────────────── // FREQUENCY-BASED COUPON ENGINE // ───────────────────────────────────────────────────────────── async generateFrequencyBasedCoupon(options: { matchIds?: string[]; maxMatches?: number; minSignal?: number; markets?: string[]; }): Promise { const maxMatches = options.maxMatches ?? 3; const minSignal = options.minSignal ?? 0.70; const allowedMarkets = options.markets?.map((m) => m.toUpperCase()) || null; this.logger.log( `[FrequencyCoupon] Starting — max=${maxMatches}, minSignal=${minSignal}`, ); // 1. Yaklaşan maçları oranlarıyla getir const upcomingRows = await this.frequencyEngine.getUpcomingMatchesWithOdds( options.matchIds, 80, ); this.logger.log( `[FrequencyCoupon] Found ${upcomingRows.length} upcoming matches with odds`, ); if (upcomingRows.length === 0) { return { strategy: "FREQUENCY", generated_at: new Date().toISOString(), bets: [], total_odds: 0, expected_hit_rate: 0, expected_value: 0, ev_positive: false, reasoning: ["Bültende uygun maç bulunamadı."], rejected_matches: [], }; } // 2. Her maç için frekans sinyallerini hesapla (paralel) const candidatePromises = upcomingRows.map((row) => this.frequencyEngine.buildMatchCandidate(row).then((candidate) => ({ candidate, row, })), ); const candidateResults = await Promise.all(candidatePromises); // 3. Sinyali olan adayları filtrele const allCandidates: Array<{ candidate: MatchCandidate; row: (typeof upcomingRows)[0]; }> = []; const rejected: FrequencyCouponResult["rejected_matches"] = []; for (const { candidate, row } of candidateResults) { if (!candidate) { rejected.push({ match_id: row.match_id, match_name: `${row.home_team_name} vs ${row.away_team_name}`, reason: `Yetersiz geçmiş veri (min ${3} maç gerekli)`, }); continue; } // Market filtresi uygula let filteredSignals = candidate.signals; if (allowedMarkets) { filteredSignals = filteredSignals.filter((s) => allowedMarkets.some((m) => s.market.includes(m)), ); } // Min signal filtresi filteredSignals = filteredSignals.filter( (s) => s.combinedSignal >= minSignal, ); if (filteredSignals.length === 0) { rejected.push({ match_id: row.match_id, match_name: `${row.home_team_name} vs ${row.away_team_name}`, reason: `Kombinasyon sinyali ${(minSignal * 100).toFixed(0)}% eşiğinin altında`, }); continue; } // En güçlü sinyali seç candidate.signals = filteredSignals; candidate.bestSignal = filteredSignals[0]; allCandidates.push({ candidate, row }); } this.logger.log( `[FrequencyCoupon] ${allCandidates.length} candidates passed filters, ${rejected.length} rejected`, ); // 4. En güçlü sinyale göre sırala allCandidates.sort( (a, b) => (b.candidate.bestSignal?.confidence ?? 0) - (a.candidate.bestSignal?.confidence ?? 0), ); // 5. Çeşitlilik: aynı ligden max 2 maç const selected: typeof allCandidates = []; const leagueCount = new Map(); for (const entry of allCandidates) { if (selected.length >= maxMatches) break; const lid = entry.candidate.leagueId; const currentCount = leagueCount.get(lid) || 0; if (currentCount >= 2) { rejected.push({ match_id: entry.candidate.matchId, match_name: `${entry.candidate.homeTeamName} vs ${entry.candidate.awayTeamName}`, reason: `Aynı ligden zaten 2 maç seçildi (${entry.candidate.leagueName})`, }); continue; } selected.push(entry); leagueCount.set(lid, currentCount + 1); } // 6. Sonucu oluştur const bets: FrequencyCouponResult["bets"] = []; let totalOdds = 1; let combinedHitRate = 1; const reasoning: string[] = []; for (const { candidate, row } of selected) { const signal = candidate.bestSignal!; const betOdds = this.frequencyEngine.getMarketOdds(row, signal.market); if (betOdds <= 0) continue; const homeBand = this.frequencyEngine.getOddsBand(candidate.homeOdds); const awayBand = this.frequencyEngine.getOddsBand(candidate.awayOdds); // Lig profili belirle let leagueProfile = "NORMAL"; if (signal.leagueBonus > 0.02) leagueProfile = "GOLCU"; else if (signal.leagueBonus < -0.02) leagueProfile = "DEFANSIF"; bets.push({ match_id: candidate.matchId, match_name: `${candidate.homeTeamName} vs ${candidate.awayTeamName}`, league: candidate.leagueName, market: signal.market, pick: signal.pick, home_signal: parseFloat(signal.homeSignal.toFixed(3)), away_signal: parseFloat(signal.awaySignal.toFixed(3)), combined_signal: parseFloat(signal.combinedSignal.toFixed(3)), league_profile: leagueProfile, historical_hit_rate: parseFloat(signal.combinedSignal.toFixed(3)), odds: betOdds, home_odds_band: homeBand, away_odds_band: awayBand, home_match_count: signal.homeMatchCount, away_match_count: signal.awayMatchCount, }); totalOdds *= betOdds; combinedHitRate *= signal.combinedSignal; reasoning.push( `${candidate.homeTeamName} vs ${candidate.awayTeamName}: ` + `${signal.pick} — Ev(${homeBand}): ${(signal.homeSignal * 100).toFixed(0)}% (${signal.homeMatchCount} maç), ` + `Dep(${awayBand}): ${(signal.awaySignal * 100).toFixed(0)}% (${signal.awayMatchCount} maç)`, ); } totalOdds = parseFloat(totalOdds.toFixed(2)); const expectedValue = parseFloat((combinedHitRate * totalOdds).toFixed(3)); return { strategy: "FREQUENCY", generated_at: new Date().toISOString(), bets, total_odds: totalOdds, expected_hit_rate: parseFloat(combinedHitRate.toFixed(4)), expected_value: expectedValue, ev_positive: expectedValue > 1.0, reasoning, rejected_matches: rejected, }; } } // ───────────────────────────────────────────────────────────── // Frequency Coupon Result Interface // ───────────────────────────────────────────────────────────── export interface FrequencyCouponResult { strategy: "FREQUENCY"; generated_at: string; bets: Array<{ match_id: string; match_name: string; league: string; market: string; pick: string; home_signal: number; away_signal: number; combined_signal: number; league_profile: string; historical_hit_rate: number; odds: number; home_odds_band: string; away_odds_band: string; home_match_count: number; away_match_count: number; }>; total_odds: number; expected_hit_rate: number; expected_value: number; ev_positive: boolean; reasoning: string[]; rejected_matches: Array<{ match_id: string; match_name: string; reason: string; }>; } 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`;