From 17ace9bd127c6ce6f95dfd887fae4c36e9b84c7b Mon Sep 17 00:00:00 2001 From: Fahri Can Date: Sun, 17 May 2026 02:09:04 +0300 Subject: [PATCH] feat: Ollama AI expert commentary integration - OllamaClient utility for llama3.2:3b API calls (timeout 30s, non-fatal) - OllamaCommentary service builds structured Turkish prompt from prediction data - PredictionsService enriches response with ai_expert_commentary field - Frontend prediction-card displays AI commentary section above match_commentary --- src/common/utils/ollama-client.ts | 47 ++++++++ src/common/utils/ollama-commentary.ts | 106 ++++++++++++++++++ .../predictions/predictions.controller.ts | 15 ++- .../predictions/predictions.service.ts | 14 +++ 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 src/common/utils/ollama-client.ts create mode 100644 src/common/utils/ollama-commentary.ts diff --git a/src/common/utils/ollama-client.ts b/src/common/utils/ollama-client.ts new file mode 100644 index 0000000..2c00132 --- /dev/null +++ b/src/common/utils/ollama-client.ts @@ -0,0 +1,47 @@ +import { Logger } from "@nestjs/common"; + +const logger = new Logger("OllamaClient"); + +const OLLAMA_BASE_URL = process.env.OLLAMA_URL ?? "http://172.22.0.1:11434"; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "llama3.2:3b"; +const TIMEOUT_MS = 30_000; + +export async function generateOllamaText(prompt: string): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + const res = await fetch(`${OLLAMA_BASE_URL}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + model: OLLAMA_MODEL, + prompt, + stream: false, + options: { + temperature: 0.6, + num_predict: 250, + repeat_penalty: 1.1, + }, + }), + }); + + clearTimeout(timer); + + if (!res.ok) { + logger.warn(`Ollama HTTP ${res.status}`); + return null; + } + + const data = await res.json() as { response?: string }; + return data.response?.trim() ?? null; + } catch (err: unknown) { + if ((err as Error).name === "AbortError") { + logger.warn("Ollama request timed out"); + } else { + logger.warn(`Ollama error: ${(err as Error).message}`); + } + return null; + } +} diff --git a/src/common/utils/ollama-commentary.ts b/src/common/utils/ollama-commentary.ts new file mode 100644 index 0000000..d92d03d --- /dev/null +++ b/src/common/utils/ollama-commentary.ts @@ -0,0 +1,106 @@ +import { generateOllamaText } from "./ollama-client"; + +interface PredictionData { + match_info?: Record; + score_prediction?: Record; + market_board?: Record; + risk?: Record; + bet_advice?: Record; + scenario_top5?: Array<{ score: string; prob: number }>; + v27_engine?: Record; + data_quality?: Record; +} + +function buildPrompt(p: PredictionData): string { + const mi = (p.match_info ?? {}) as Record; + const sp = (p.score_prediction ?? {}) as Record; + const mb = (p.market_board ?? {}) as Record; + const risk = (p.risk ?? {}) as Record; + const ba = (p.bet_advice ?? {}) as Record; + const dq = (p.data_quality ?? {}) as Record; + + const home = String(mi.home_team ?? "Ev Sahibi"); + const away = String(mi.away_team ?? "Deplasman"); + const league = String(mi.league ?? ""); + + const ms = (mb.MS ?? {}) as Record; + const msProbs = (ms.probs ?? {}) as Record; + const ou15 = (mb.OU15 ?? {}) as Record; + const ou25 = (mb.OU25 ?? {}) as Record; + const ou35 = (mb.OU35 ?? {}) as Record; + const btts = (mb.BTTS ?? {}) as Record; + + const homeWin = Math.round((msProbs["1"] ?? 0) * 100); + const draw = Math.round((msProbs["X"] ?? 0) * 100); + const awayWin = Math.round((msProbs["2"] ?? 0) * 100); + + const ou15Probs = (ou15.probs ?? {}) as Record; + const ou25Probs = (ou25.probs ?? {}) as Record; + const ou35Probs = (ou35.probs ?? {}) as Record; + const bttsProbs = (btts.probs ?? {}) as Record; + + const xgHome = Number(sp.xg_home ?? 0).toFixed(2); + const xgAway = Number(sp.xg_away ?? 0).toFixed(2); + const ftScore = String(sp.ft ?? ""); + + const ou15Over = Math.round((ou15Probs.over ?? 0) * 100); + const ou25Over = Math.round((ou25Probs.over ?? 0) * 100); + const ou35Under = Math.round((ou35Probs.under ?? 0) * 100); + const bttsYes = Math.round((bttsProbs.yes ?? 0) * 100); + + const riskLevel = String(risk.level ?? ""); + const surpriseScore = Number(risk.surprise_score ?? 0); + const playable = Boolean(ba.playable); + const lineupSource = String(dq.lineup_source ?? ""); + + const top5 = (p.scenario_top5 ?? []) + .slice(0, 3) + .map((s) => `${s.score} (%${Math.round(s.prob * 100)})`) + .join(", "); + + // V27 MS probs if available + const v27 = (p.v27_engine ?? {}) as Record; + const v27ms = (v27.predictions as Record)?.ms as Record | undefined; + const v27Home = v27ms ? Math.round(v27ms.home * 100) : null; + + const nobet = !playable + ? "Tüm oranlar çok düşük, bahis yapmak mantıklı değil." + : "Bahis açılabilir."; + + const xgComment = + Number(xgHome) > Number(xgAway) * 2 + ? `${home} xG açısından çok üstün (${xgHome} - ${xgAway}), ${away} gol atacak alan bulamıyor.` + : Number(xgAway) > Number(xgHome) * 2 + ? `${away} xG açısından çok üstün (${xgAway} - ${xgHome}), ${home} etkisiz kalıyor.` + : `İki takım xG açısından yakın (${xgHome} - ${xgAway}).`; + + const goalComment = + ou15Over >= 80 + ? "Maçta kesinlikle gol bekleniyor." + : ou15Over >= 65 + ? "Gol ihtimali yüksek." + : "Golsüz geçebilir."; + + const highSurprise = surpriseScore >= 60; + + return `Aşağıdaki futbol maçı için Türkçe uzman yorum yaz. Sadece Türkçe kullan, İngilizce kelime yasak. 3 cümle yaz, kısa tut. + +Maç: ${home} - ${away} +${home} kazanma ihtimali: %${homeWin}${v27Home ? `, güçlü model: %${v27Home}` : ""} +Beraberlik: %${draw}, ${away} kazanır: %${awayWin} +${xgComment} +${goalComment} (1.5 üst: %${ou15Over}, 2.5 üst: %${ou25Over}) +En olası skor: ${ftScore}. İkili bahis olasılığı: %${bttsYes}. +Risk: ${riskLevel}${highSurprise ? ", sürpriz olabilir" : ""}. +${nobet} + +Yorum:`; + +} + +export async function generateExpertCommentary( + prediction: PredictionData, +): Promise { + const prompt = buildPrompt(prediction); + return generateOllamaText(prompt); +} diff --git a/src/modules/predictions/predictions.controller.ts b/src/modules/predictions/predictions.controller.ts index 324af1b..d65c45b 100755 --- a/src/modules/predictions/predictions.controller.ts +++ b/src/modules/predictions/predictions.controller.ts @@ -4,6 +4,7 @@ import { Post, Body, Param, + Query, HttpCode, HttpStatus, NotFoundException, @@ -111,6 +112,7 @@ export class PredictionsController { async getPrediction( @Param("matchId") matchId: string, @CurrentUser() user: any, + @Query("nocache") nocache?: string, ): Promise { const canProceed = await this.analysisService.checkUsageLimit( user.id, @@ -121,13 +123,16 @@ export class PredictionsController { throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED"); } - const cached = await this.predictionsService.getCachedPrediction(matchId); - if (cached) { - // Do not record usage for cached predictions - return cached; + const bypassCache = nocache === "true" || nocache === "1"; + + if (!bypassCache) { + const cached = await this.predictionsService.getCachedPrediction(matchId); + if (cached) { + return cached; + } } - // Get from AI Engine + // Get from AI Engine (always fresh when nocache=true) const prediction = await this.predictionsService.getPredictionById(matchId); if (!prediction) { diff --git a/src/modules/predictions/predictions.service.ts b/src/modules/predictions/predictions.service.ts index a0f3c93..cbb4977 100755 --- a/src/modules/predictions/predictions.service.ts +++ b/src/modules/predictions/predictions.service.ts @@ -31,6 +31,7 @@ import { AiEngineClient, AiEngineRequestError, } from "../../common/utils/ai-engine-client"; +import { generateExpertCommentary } from "../../common/utils/ollama-commentary"; type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW"; @@ -237,6 +238,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy { response.data, matchContext, ); + + // Generate AI expert commentary via Ollama (non-blocking, best-effort) + try { + const aiCommentary = await generateExpertCommentary( + response.data as unknown as Record, + ); + if (aiCommentary) { + (prediction as unknown as Record).ai_expert_commentary = aiCommentary; + } + } catch { + // Ollama failure is non-fatal + } + await this.recordPredictionRun(matchId, response.data); await this.cachePrediction(matchId, prediction); return prediction;