feat: Ollama AI expert commentary integration
Deploy Iddaai Backend / build-and-deploy (push) Successful in 37s

- 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
This commit is contained in:
2026-05-17 02:09:04 +03:00
parent 2b87669f41
commit 17ace9bd12
4 changed files with 177 additions and 5 deletions
+47
View File
@@ -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<string | null> {
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;
}
}
+106
View File
@@ -0,0 +1,106 @@
import { generateOllamaText } from "./ollama-client";
interface PredictionData {
match_info?: Record<string, unknown>;
score_prediction?: Record<string, unknown>;
market_board?: Record<string, unknown>;
risk?: Record<string, unknown>;
bet_advice?: Record<string, unknown>;
scenario_top5?: Array<{ score: string; prob: number }>;
v27_engine?: Record<string, unknown>;
data_quality?: Record<string, unknown>;
}
function buildPrompt(p: PredictionData): string {
const mi = (p.match_info ?? {}) as Record<string, unknown>;
const sp = (p.score_prediction ?? {}) as Record<string, unknown>;
const mb = (p.market_board ?? {}) as Record<string, unknown>;
const risk = (p.risk ?? {}) as Record<string, unknown>;
const ba = (p.bet_advice ?? {}) as Record<string, unknown>;
const dq = (p.data_quality ?? {}) as Record<string, unknown>;
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<string, unknown>;
const msProbs = (ms.probs ?? {}) as Record<string, number>;
const ou15 = (mb.OU15 ?? {}) as Record<string, unknown>;
const ou25 = (mb.OU25 ?? {}) as Record<string, unknown>;
const ou35 = (mb.OU35 ?? {}) as Record<string, unknown>;
const btts = (mb.BTTS ?? {}) as Record<string, unknown>;
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<string, number>;
const ou25Probs = (ou25.probs ?? {}) as Record<string, number>;
const ou35Probs = (ou35.probs ?? {}) as Record<string, number>;
const bttsProbs = (btts.probs ?? {}) as Record<string, number>;
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<string, unknown>;
const v27ms = (v27.predictions as Record<string, unknown>)?.ms as Record<string, number> | 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<string | null> {
const prompt = buildPrompt(prediction);
return generateOllamaText(prompt);
}
@@ -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<MatchPredictionDto> {
const canProceed = await this.analysisService.checkUsageLimit(
user.id,
@@ -121,13 +123,16 @@ export class PredictionsController {
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
}
const bypassCache = nocache === "true" || nocache === "1";
if (!bypassCache) {
const cached = await this.predictionsService.getCachedPrediction(matchId);
if (cached) {
// Do not record usage for cached predictions
return cached;
}
}
// Get from AI Engine
// Get from AI Engine (always fresh when nocache=true)
const prediction = await this.predictionsService.getPredictionById(matchId);
if (!prediction) {
@@ -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<string, unknown>,
);
if (aiCommentary) {
(prediction as unknown as Record<string, unknown>).ai_expert_commentary = aiCommentary;
}
} catch {
// Ollama failure is non-fatal
}
await this.recordPredictionRun(matchId, response.data);
await this.cachePrediction(matchId, prediction);
return prediction;