feat: Ollama AI expert commentary integration
Deploy Iddaai Backend / build-and-deploy (push) Successful in 37s
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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -111,6 +112,7 @@ export class PredictionsController {
|
|||||||
async getPrediction(
|
async getPrediction(
|
||||||
@Param("matchId") matchId: string,
|
@Param("matchId") matchId: string,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
|
@Query("nocache") nocache?: string,
|
||||||
): Promise<MatchPredictionDto> {
|
): Promise<MatchPredictionDto> {
|
||||||
const canProceed = await this.analysisService.checkUsageLimit(
|
const canProceed = await this.analysisService.checkUsageLimit(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -121,13 +123,16 @@ export class PredictionsController {
|
|||||||
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
|
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bypassCache = nocache === "true" || nocache === "1";
|
||||||
|
|
||||||
|
if (!bypassCache) {
|
||||||
const cached = await this.predictionsService.getCachedPrediction(matchId);
|
const cached = await this.predictionsService.getCachedPrediction(matchId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
// Do not record usage for cached predictions
|
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get from AI Engine
|
// Get from AI Engine (always fresh when nocache=true)
|
||||||
const prediction = await this.predictionsService.getPredictionById(matchId);
|
const prediction = await this.predictionsService.getPredictionById(matchId);
|
||||||
|
|
||||||
if (!prediction) {
|
if (!prediction) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
AiEngineClient,
|
AiEngineClient,
|
||||||
AiEngineRequestError,
|
AiEngineRequestError,
|
||||||
} from "../../common/utils/ai-engine-client";
|
} from "../../common/utils/ai-engine-client";
|
||||||
|
import { generateExpertCommentary } from "../../common/utils/ollama-commentary";
|
||||||
|
|
||||||
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";
|
||||||
|
|
||||||
@@ -237,6 +238,19 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
response.data,
|
response.data,
|
||||||
matchContext,
|
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.recordPredictionRun(matchId, response.data);
|
||||||
await this.cachePrediction(matchId, prediction);
|
await this.cachePrediction(matchId, prediction);
|
||||||
return prediction;
|
return prediction;
|
||||||
|
|||||||
Reference in New Issue
Block a user