408 lines
17 KiB
Python
408 lines
17 KiB
Python
"""
|
||
Match Commentary Generator
|
||
===========================
|
||
Generates human-readable Turkish commentary from the analysis package.
|
||
Reads all engine signals (model, odds band, betting brain, triple value)
|
||
and produces a clear, actionable summary for end users.
|
||
|
||
No LLM required — fully template-based.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
|
||
def generate_match_commentary(package: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
Main entry point. Takes a full analysis package and returns a commentary dict.
|
||
|
||
Returns:
|
||
{
|
||
"action": "BET" | "WATCH" | "SKIP",
|
||
"headline": "...",
|
||
"summary": "...",
|
||
"notes": ["...", "..."],
|
||
"contradictions": ["...", "..."],
|
||
"confidence_label": "YÜKSEK" | "ORTA" | "DÜŞÜK" | "ÇOK DÜŞÜK"
|
||
}
|
||
"""
|
||
match_info = package.get("match_info") or {}
|
||
home = match_info.get("home_team", "Ev Sahibi")
|
||
away = match_info.get("away_team", "Deplasman")
|
||
main_pick = package.get("main_pick") or {}
|
||
betting_brain = package.get("betting_brain") or {}
|
||
v27_engine = package.get("v27_engine") or {}
|
||
market_board = package.get("market_board") or {}
|
||
score_pred = package.get("score_prediction") or {}
|
||
risk = package.get("risk") or {}
|
||
data_quality = package.get("data_quality") or {}
|
||
|
||
# ── Determine action ──────────────────────────────────────────
|
||
brain_decision = str(betting_brain.get("decision") or "NO_BET").upper()
|
||
main_playable = bool(main_pick.get("playable"))
|
||
main_vetoed = bool((main_pick.get("upper_brain") or {}).get("veto"))
|
||
approved_count = int(betting_brain.get("approved_count", 0) or 0)
|
||
|
||
if main_playable and not main_vetoed and approved_count > 0:
|
||
action = "BET"
|
||
elif approved_count == 0 and brain_decision == "NO_BET":
|
||
action = "SKIP"
|
||
else:
|
||
action = "WATCH"
|
||
|
||
# ── Headline ──────────────────────────────────────────────────
|
||
headline = _build_headline(action, main_pick, home, away)
|
||
|
||
# ── Summary paragraph ─────────────────────────────────────────
|
||
summary = _build_summary(
|
||
action, main_pick, market_board, v27_engine,
|
||
score_pred, risk, data_quality, home, away,
|
||
match_info=match_info,
|
||
)
|
||
|
||
# ── Quick notes ───────────────────────────────────────────────
|
||
notes = _build_notes(market_board, v27_engine, score_pred, risk, home, away, league_name=match_info.get("league", ""))
|
||
|
||
# ── Contradiction detection ───────────────────────────────────
|
||
contradictions = _detect_contradictions(market_board, v27_engine, package)
|
||
|
||
# ── Overall confidence label ──────────────────────────────────
|
||
confidence_label = _overall_confidence_label(main_pick, data_quality)
|
||
|
||
return {
|
||
"action": action,
|
||
"headline": headline,
|
||
"summary": summary,
|
||
"notes": notes[:6],
|
||
"contradictions": contradictions[:4],
|
||
"confidence_label": confidence_label,
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# Headline
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def _build_headline(
|
||
action: str,
|
||
main_pick: Dict[str, Any],
|
||
home: str,
|
||
away: str,
|
||
) -> str:
|
||
if action == "BET":
|
||
market = main_pick.get("market", "")
|
||
pick = main_pick.get("pick", "")
|
||
odds = main_pick.get("odds", 0.0)
|
||
conf = main_pick.get("calibrated_confidence", main_pick.get("confidence", 0))
|
||
market_tr = _market_to_turkish(market, pick)
|
||
return f"🎯 {market_tr} önerisi — Oran: {odds}, Güven: %{conf:.0f}"
|
||
|
||
if action == "WATCH":
|
||
return f"👀 {home} vs {away} — İzlemeye değer sinyaller var"
|
||
|
||
return f"⚠️ {home} vs {away} — Şu an net bir fırsat görülmüyor"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# Summary
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def _build_summary(
|
||
action: str,
|
||
main_pick: Dict[str, Any],
|
||
market_board: Dict[str, Any],
|
||
v27_engine: Dict[str, Any],
|
||
score_pred: Dict[str, Any],
|
||
risk: Dict[str, Any],
|
||
data_quality: Dict[str, Any],
|
||
home: str,
|
||
away: str,
|
||
match_info: Optional[Dict[str, Any]] = None,
|
||
) -> str:
|
||
parts: List[str] = []
|
||
|
||
# C-2: live-aware preamble — if the match is in play, lead with current score
|
||
# vs the pre-match read so users immediately see how the prediction is faring.
|
||
match_info = match_info or {}
|
||
if match_info.get("is_live"):
|
||
cur_home = match_info.get("current_score_home")
|
||
cur_away = match_info.get("current_score_away")
|
||
if cur_home is not None and cur_away is not None:
|
||
parts.append(
|
||
f"🔴 CANLI: {home} {cur_home} - {cur_away} {away} "
|
||
f"(aşağıdaki analiz maç öncesi tahmindir)"
|
||
)
|
||
|
||
# Who is the favourite?
|
||
ms_board = market_board.get("MS") or {}
|
||
ms_pick = ms_board.get("pick", "")
|
||
ms_conf = float(ms_board.get("confidence", 50) or 50)
|
||
|
||
if ms_pick == "1" and ms_conf > 55:
|
||
parts.append(f"{home} net favori")
|
||
elif ms_pick == "1" and ms_conf > 45:
|
||
parts.append(f"{home} hafif favori görünüyor")
|
||
elif ms_pick == "2" and ms_conf > 55:
|
||
parts.append(f"{away} net favori")
|
||
elif ms_pick == "2" and ms_conf > 45:
|
||
parts.append(f"{away} hafif favori görünüyor")
|
||
else:
|
||
parts.append("İki takım da birbirine yakın güçte")
|
||
|
||
# xG expectation
|
||
xg_home = float(score_pred.get("xg_home", 0) or 0)
|
||
xg_away = float(score_pred.get("xg_away", 0) or 0)
|
||
xg_total = xg_home + xg_away
|
||
if xg_total > 3.0:
|
||
parts.append(f"Gol beklentisi yüksek (toplam xG: {xg_total:.1f})")
|
||
elif xg_total < 2.0:
|
||
parts.append(f"Düşük gol beklentisi (toplam xG: {xg_total:.1f})")
|
||
|
||
# Consensus check
|
||
consensus = str(v27_engine.get("consensus") or "").upper()
|
||
if consensus == "AGREE":
|
||
parts.append("Model motorları aynı fikirde")
|
||
elif consensus == "DISAGREE":
|
||
parts.append("Model motorları farklı sonuçlara ulaşıyor — belirsizlik var")
|
||
|
||
# Action-specific
|
||
if action == "BET":
|
||
market_tr = _market_to_turkish(
|
||
main_pick.get("market", ""), main_pick.get("pick", "")
|
||
)
|
||
edge = float(main_pick.get("ev_edge", 0) or 0)
|
||
parts.append(
|
||
f"{market_tr} yönünde değer tespit edildi (EV edge: {edge:+.1%})"
|
||
)
|
||
elif action == "SKIP":
|
||
parts.append(
|
||
"Hiçbir markette piyasanın fiyatlamadığı bir avantaj görülmüyor"
|
||
)
|
||
|
||
# Risk
|
||
risk_level = str(risk.get("level") or "MEDIUM").upper()
|
||
if risk_level == "HIGH":
|
||
parts.append("⚠️ Risk seviyesi yüksek")
|
||
elif risk_level == "EXTREME":
|
||
parts.append("🔴 Çok yüksek risk — dikkatli olun")
|
||
|
||
# Data quality
|
||
quality_label = str(data_quality.get("label") or "MEDIUM").upper()
|
||
if quality_label == "LOW":
|
||
parts.append("Veri kalitesi düşük — tahminler daha az güvenilir")
|
||
|
||
return ". ".join(parts) + "."
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# Quick Notes
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def _build_notes(
|
||
market_board: Dict[str, Any],
|
||
v27_engine: Dict[str, Any],
|
||
score_pred: Dict[str, Any],
|
||
risk: Dict[str, Any],
|
||
home: str,
|
||
away: str,
|
||
league_name: str = "",
|
||
) -> List[str]:
|
||
notes: List[str] = []
|
||
triple_value = v27_engine.get("triple_value") or {}
|
||
odds_band = v27_engine.get("odds_band") or {}
|
||
|
||
# Cup game note — model uses league statistics; cup dynamics differ
|
||
_cup_kws = ("kupa", "cup", "coupe", "copa", "pokal", "ziraat", "trophy", "shield", "super cup", "süper kupa")
|
||
if any(kw in (league_name or "").lower() for kw in _cup_kws):
|
||
notes.append("⚠️ Kupa maçı: ev avantajı zayıf, rotasyon ve düşük motivasyon riski var")
|
||
|
||
# MS note
|
||
ms = market_board.get("MS") or {}
|
||
ms_conf = float(ms.get("confidence", 0) or 0)
|
||
if ms_conf < 45:
|
||
notes.append("Maç sonucu belirsiz, net favori yok")
|
||
elif ms.get("pick") == "1":
|
||
notes.append(f"{home} favori ama oran değerli mi kontrol et")
|
||
elif ms.get("pick") == "2":
|
||
notes.append(f"{away} favori ama oran değerli mi kontrol et")
|
||
|
||
# OU25 note
|
||
ou25 = market_board.get("OU25") or {}
|
||
ou25_probs = ou25.get("probs") or {}
|
||
over_prob = float(ou25_probs.get("over", 0.5) or 0.5)
|
||
if over_prob > 0.58:
|
||
notes.append("2.5 Üst yönünde eğilim var")
|
||
elif over_prob < 0.42:
|
||
notes.append("2.5 Alt yönünde eğilim var")
|
||
else:
|
||
notes.append("2.5 Üst/Alt dengeli — kesin sinyal yok")
|
||
|
||
# BTTS note
|
||
btts = market_board.get("BTTS") or {}
|
||
btts_probs = btts.get("probs") or {}
|
||
btts_yes = float(btts_probs.get("yes", 0.5) or 0.5)
|
||
if btts_yes > 0.58:
|
||
notes.append("Her iki takımın da gol atması bekleniyor")
|
||
elif btts_yes < 0.42:
|
||
notes.append("KG olasılığı düşük")
|
||
|
||
# HT note
|
||
ht = market_board.get("HT") or {}
|
||
ht_pick = ht.get("pick", "")
|
||
ht_conf = float(ht.get("confidence", 0) or 0)
|
||
if ht_conf > 40 and ht_pick:
|
||
ht_label = {"1": f"İY {home}", "2": f"İY {away}", "X": "İY beraberlik"}.get(
|
||
ht_pick, f"İY {ht_pick}"
|
||
)
|
||
notes.append(f"{ht_label} yönünde hafif sinyal (%{ht_conf:.0f})")
|
||
|
||
# Risk warnings
|
||
warnings = risk.get("warnings") or []
|
||
for w in warnings[:2]:
|
||
notes.append(f"⚠️ {w}")
|
||
|
||
return notes
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# Contradiction Detection
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def _detect_contradictions(
|
||
market_board: Dict[str, Any],
|
||
v27_engine: Dict[str, Any],
|
||
package: Dict[str, Any],
|
||
) -> List[str]:
|
||
"""
|
||
Detect cases where model prediction and odds band/triple value
|
||
point in opposite directions — the user's main complaint.
|
||
"""
|
||
contradictions: List[str] = []
|
||
triple_value = v27_engine.get("triple_value") or {}
|
||
predictions = v27_engine.get("predictions") or {}
|
||
|
||
# C-2 live-vs-prediction mismatch
|
||
match_info = package.get("match_info") or {}
|
||
if match_info.get("is_live"):
|
||
cur_h = match_info.get("current_score_home")
|
||
cur_a = match_info.get("current_score_away")
|
||
ms_board_live = market_board.get("MS") or {}
|
||
predicted_pick = str(ms_board_live.get("pick") or "")
|
||
if cur_h is not None and cur_a is not None:
|
||
actual_pick: Optional[str] = None
|
||
if cur_h > cur_a:
|
||
actual_pick = "1"
|
||
elif cur_a > cur_h:
|
||
actual_pick = "2"
|
||
else:
|
||
actual_pick = "X"
|
||
if predicted_pick and actual_pick and predicted_pick != actual_pick:
|
||
contradictions.append(
|
||
"Canlı durum maç öncesi tahmin ile çelişiyor — sürpriz GERÇEKLEŞİYOR"
|
||
)
|
||
|
||
# MS contradiction: model says home but triple_value says away has value
|
||
ms_preds = predictions.get("ms") or {}
|
||
ms_home = float(ms_preds.get("home", 0) or 0)
|
||
ms_away = float(ms_preds.get("away", 0) or 0)
|
||
home_triple = triple_value.get("home") or {}
|
||
away_triple = triple_value.get("away") or {}
|
||
|
||
model_favours_home = ms_home > ms_away
|
||
away_is_value = bool(away_triple.get("is_value"))
|
||
home_is_value = bool(home_triple.get("is_value"))
|
||
|
||
if model_favours_home and away_is_value:
|
||
contradictions.append(
|
||
"Model ev sahibini favori görüyor ama oran bandı deplasmanda değer buluyor — "
|
||
"bu çelişki nedeniyle MS tahminine dikkatli yaklaş"
|
||
)
|
||
elif not model_favours_home and home_is_value:
|
||
contradictions.append(
|
||
"Model deplasmanı favori görüyor ama oran bandı ev sahibinde değer buluyor — "
|
||
"bu çelişki nedeniyle MS tahminine dikkatli yaklaş"
|
||
)
|
||
|
||
# HT contradiction
|
||
ht_board = market_board.get("HT") or {}
|
||
ht_pick = ht_board.get("pick", "")
|
||
ht_home_triple = triple_value.get("ht_home") or {}
|
||
ht_away_triple = triple_value.get("ht_away") or {}
|
||
|
||
if ht_pick == "1" and bool(ht_away_triple.get("is_value")):
|
||
contradictions.append(
|
||
"Model İY ev sahibi diyor ama oran bandı İY deplasmanda değer buluyor — "
|
||
"İY tahmini güvenilir değil"
|
||
)
|
||
elif ht_pick == "2" and bool(ht_home_triple.get("is_value")):
|
||
contradictions.append(
|
||
"Model İY deplasman diyor ama oran bandı İY ev sahibinde değer buluyor — "
|
||
"İY tahmini güvenilir değil"
|
||
)
|
||
|
||
# OU25 contradiction
|
||
ou25_board = market_board.get("OU25") or {}
|
||
ou25_pick = ou25_board.get("pick", "")
|
||
ou25_over_triple = triple_value.get("ou25_over") or {}
|
||
ou25_under_triple = triple_value.get("ou25_under") or {}
|
||
|
||
if ou25_pick == "Üst" and bool(ou25_under_triple.get("is_value")):
|
||
contradictions.append(
|
||
"Model 2.5 Üst diyor ama oran bandı 2.5 Alt'ta değer buluyor — çelişki var"
|
||
)
|
||
elif ou25_pick == "Alt" and bool(ou25_over_triple.get("is_value")):
|
||
contradictions.append(
|
||
"Model 2.5 Alt diyor ama oran bandı 2.5 Üst'te değer buluyor — çelişki var"
|
||
)
|
||
|
||
return contradictions
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
# Helpers
|
||
# ═══════════════════════════════════════════════════════════════════════
|
||
|
||
def _overall_confidence_label(
|
||
main_pick: Dict[str, Any],
|
||
data_quality: Dict[str, Any],
|
||
) -> str:
|
||
"""Overall confidence label for the entire analysis."""
|
||
quality_score = float(data_quality.get("score", 0.5) or 0.5)
|
||
main_conf = float(
|
||
main_pick.get("calibrated_confidence", main_pick.get("confidence", 0)) or 0
|
||
)
|
||
main_playable = bool(main_pick.get("playable"))
|
||
|
||
if main_playable and main_conf >= 60 and quality_score >= 0.8:
|
||
return "YÜKSEK"
|
||
if main_playable and main_conf >= 45:
|
||
return "ORTA"
|
||
if main_conf >= 30:
|
||
return "DÜŞÜK"
|
||
return "ÇOK DÜŞÜK"
|
||
|
||
|
||
_MARKET_TR_MAP = {
|
||
"MS": {"1": "Maç Sonucu Ev Sahibi", "2": "Maç Sonucu Deplasman", "X": "Beraberlik"},
|
||
"DC": {"1X": "Çifte Şans 1X", "X2": "Çifte Şans X2", "12": "Çifte Şans 12"},
|
||
"OU25": {"Üst": "2.5 Üst", "Alt": "2.5 Alt", "Over": "2.5 Üst", "Under": "2.5 Alt"},
|
||
"OU15": {"Üst": "1.5 Üst", "Alt": "1.5 Alt", "Over": "1.5 Üst", "Under": "1.5 Alt"},
|
||
"OU35": {"Üst": "3.5 Üst", "Alt": "3.5 Alt", "Over": "3.5 Üst", "Under": "3.5 Alt"},
|
||
"BTTS": {"KG Var": "Karşılıklı Gol Var", "KG Yok": "Karşılıklı Gol Yok",
|
||
"Yes": "Karşılıklı Gol Var", "No": "Karşılıklı Gol Yok"},
|
||
"HT": {"1": "İlk Yarı Ev Sahibi", "2": "İlk Yarı Deplasman", "X": "İlk Yarı Beraberlik"},
|
||
"HT_OU05": {"Üst": "İY 0.5 Üst", "Alt": "İY 0.5 Alt"},
|
||
"HT_OU15": {"Üst": "İY 1.5 Üst", "Alt": "İY 1.5 Alt"},
|
||
"OE": {"Tek": "Tek", "Çift": "Çift", "Odd": "Tek", "Even": "Çift"},
|
||
"CARDS": {"Üst": "Kart Üst", "Alt": "Kart Alt"},
|
||
}
|
||
|
||
|
||
def _market_to_turkish(market: str, pick: str) -> str:
|
||
market_map = _MARKET_TR_MAP.get(market, {})
|
||
result = market_map.get(pick)
|
||
if result:
|
||
return result
|
||
return f"{market} {pick}"
|