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,
|
||||
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 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user