Spaces:
Runtime error
Runtime error
World Cup Agent commited on
Commit ·
d6b171a
1
Parent(s): 6797e3f
feat: confidence hover tooltip with CONFIDENCE_REASON field
Browse files- app.py +152 -3
- predict.py +5 -3
app.py
CHANGED
|
@@ -4,6 +4,7 @@ FastAPI app deployed on HF Spaces. Renders HTML directly (no Jinja2).
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import json
|
|
|
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
|
|
@@ -111,6 +112,9 @@ details summary::-webkit-details-marker { display: none; }
|
|
| 111 |
details summary { outline: none; }
|
| 112 |
details[open] .match-header { border-bottom: 1px solid #222; }
|
| 113 |
details[open] summary span.chevron { transform: rotate(180deg); display: inline-block; }
|
|
|
|
|
|
|
|
|
|
| 114 |
</style>
|
| 115 |
"""
|
| 116 |
|
|
@@ -198,7 +202,7 @@ def _esc(s):
|
|
| 198 |
def _page(body, title="⚽ World Cup Prediction Agent"):
|
| 199 |
results = _load("results.json", [])
|
| 200 |
w, l, pct = _stats(results)
|
| 201 |
-
nav = '<div class="nav"><a href="/">Dashboard</a> · <a href="/strategy">Strategy</a></div>'
|
| 202 |
header = f"""
|
| 203 |
<header>
|
| 204 |
<h1>⚽ World Cup Prediction Agent</h1>
|
|
@@ -270,7 +274,7 @@ async def dashboard():
|
|
| 270 |
<div class="match-body">
|
| 271 |
<div class="pred-row">
|
| 272 |
<div class="pred-item"><div class="label">Pick</div><div class="value pick">{_team(_pick_display(pred.get('pick','?'), r['home'], r['away']))}</div></div>
|
| 273 |
-
<div class="pred-item"><div class="label">Confidence</div><div class="value">{pred.get("confidence","?")}/10</div></div>
|
| 274 |
<div class="pred-item"><div class="label">Research cost</div><div class="value" style="color:#f9a825;">${r.get("research_cost",0):.4f}</div></div>
|
| 275 |
</div>
|
| 276 |
<div class="reasoning">"{_esc(pred.get("reasoning",""))}"</div>
|
|
@@ -381,7 +385,7 @@ async def match_detail(match_key: str):
|
|
| 381 |
<div class="section"><h2>Prediction</h2>
|
| 382 |
<div class="pred-row">
|
| 383 |
<div class="pred-item"><div class="label">Pick</div><div class="value pick">{_team(_pick_display(pred.get('pick','?'), home, away))}</div></div>
|
| 384 |
-
<div class="pred-item"><div class="label">Confidence</div><div class="value">{pred.get("confidence","?")}/10</div></div>
|
| 385 |
</div>
|
| 386 |
<div class="reasoning">"{_esc(pred.get("reasoning",""))}"</div>
|
| 387 |
</div>
|
|
@@ -415,6 +419,151 @@ async def strategy_page():
|
|
| 415 |
return _page(body, "Strategy — World Cup Predictions")
|
| 416 |
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
@app.get("/api/results")
|
| 419 |
async def api_results():
|
| 420 |
return _load("results.json", [])
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import json
|
| 7 |
+
import subprocess
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
from pathlib import Path
|
| 10 |
|
|
|
|
| 112 |
details summary { outline: none; }
|
| 113 |
details[open] .match-header { border-bottom: 1px solid #222; }
|
| 114 |
details[open] summary span.chevron { transform: rotate(180deg); display: inline-block; }
|
| 115 |
+
.tooltip-wrap { position: relative; display: inline-block; cursor: help; border-bottom: 1px dashed #555; }
|
| 116 |
+
.tooltip-wrap .tooltip-box { visibility: hidden; opacity: 0; background: #1e1e1e; color: #ccc; font-size: 11px; line-height: 1.5; border: 1px solid #333; border-radius: 4px; padding: 8px 10px; position: absolute; z-index: 10; bottom: 125%; left: 50%; transform: translateX(-50%); width: 260px; pointer-events: none; transition: opacity 0.15s; white-space: normal; font-weight: normal; font-style: normal; }
|
| 117 |
+
.tooltip-wrap:hover .tooltip-box { visibility: visible; opacity: 1; }
|
| 118 |
</style>
|
| 119 |
"""
|
| 120 |
|
|
|
|
| 202 |
def _page(body, title="⚽ World Cup Prediction Agent"):
|
| 203 |
results = _load("results.json", [])
|
| 204 |
w, l, pct = _stats(results)
|
| 205 |
+
nav = '<div class="nav"><a href="/">Dashboard</a> · <a href="/learnings">Learnings</a> · <a href="/strategy">Strategy</a></div>'
|
| 206 |
header = f"""
|
| 207 |
<header>
|
| 208 |
<h1>⚽ World Cup Prediction Agent</h1>
|
|
|
|
| 274 |
<div class="match-body">
|
| 275 |
<div class="pred-row">
|
| 276 |
<div class="pred-item"><div class="label">Pick</div><div class="value pick">{_team(_pick_display(pred.get('pick','?'), r['home'], r['away']))}</div></div>
|
| 277 |
+
<div class="pred-item"><div class="label">Confidence</div><div class="value"><span class="tooltip-wrap">{pred.get("confidence","?")}/10<span class="tooltip-box">{_esc(pred.get("confidence_reason","Hover reason not available for this prediction."))}</span></span></div></div>
|
| 278 |
<div class="pred-item"><div class="label">Research cost</div><div class="value" style="color:#f9a825;">${r.get("research_cost",0):.4f}</div></div>
|
| 279 |
</div>
|
| 280 |
<div class="reasoning">"{_esc(pred.get("reasoning",""))}"</div>
|
|
|
|
| 385 |
<div class="section"><h2>Prediction</h2>
|
| 386 |
<div class="pred-row">
|
| 387 |
<div class="pred-item"><div class="label">Pick</div><div class="value pick">{_team(_pick_display(pred.get('pick','?'), home, away))}</div></div>
|
| 388 |
+
<div class="pred-item"><div class="label">Confidence</div><div class="value"><span class="tooltip-wrap">{pred.get("confidence","?")}/10<span class="tooltip-box">{_esc(pred.get("confidence_reason","Hover reason not available for this prediction."))}</span></span></div></div>
|
| 389 |
</div>
|
| 390 |
<div class="reasoning">"{_esc(pred.get("reasoning",""))}"</div>
|
| 391 |
</div>
|
|
|
|
| 419 |
return _page(body, "Strategy — World Cup Predictions")
|
| 420 |
|
| 421 |
|
| 422 |
+
def _strategy_history() -> list[dict]:
|
| 423 |
+
"""Return list of {match, date, sha, content, prev_content} from git log."""
|
| 424 |
+
try:
|
| 425 |
+
log = subprocess.run(
|
| 426 |
+
["git", "log", "--pretty=format:%H|%ai|%s", "--", "strategy.md"],
|
| 427 |
+
cwd=BASE, capture_output=True, text=True, check=True,
|
| 428 |
+
)
|
| 429 |
+
except Exception:
|
| 430 |
+
return []
|
| 431 |
+
|
| 432 |
+
entries = []
|
| 433 |
+
lines = [l for l in log.stdout.strip().splitlines() if l]
|
| 434 |
+
for line in lines:
|
| 435 |
+
parts = line.split("|", 2)
|
| 436 |
+
if len(parts) != 3:
|
| 437 |
+
continue
|
| 438 |
+
sha, date_str, subject = parts
|
| 439 |
+
# subject looks like "reflect: NED vs JPN" or "init: ..."
|
| 440 |
+
match_name = subject.replace("reflect: ", "").replace("init: ", "").strip()
|
| 441 |
+
try:
|
| 442 |
+
dt = datetime.fromisoformat(date_str.strip())
|
| 443 |
+
except Exception:
|
| 444 |
+
dt = None
|
| 445 |
+
try:
|
| 446 |
+
content = subprocess.run(
|
| 447 |
+
["git", "show", f"{sha}:strategy.md"],
|
| 448 |
+
cwd=BASE, capture_output=True, text=True, check=True,
|
| 449 |
+
).stdout
|
| 450 |
+
except Exception:
|
| 451 |
+
content = ""
|
| 452 |
+
entries.append({"match": match_name, "date": dt, "sha": sha, "subject": subject, "content": content})
|
| 453 |
+
|
| 454 |
+
# Chronological order (oldest first), attach previous content for diff
|
| 455 |
+
entries.reverse()
|
| 456 |
+
for i, e in enumerate(entries):
|
| 457 |
+
e["prev_content"] = entries[i - 1]["content"] if i > 0 else ""
|
| 458 |
+
return entries
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _text_diff_html(old: str, new: str) -> str:
|
| 462 |
+
"""Produce simple line-level added/removed/unchanged HTML."""
|
| 463 |
+
old_lines = old.splitlines()
|
| 464 |
+
new_lines = new.splitlines()
|
| 465 |
+
old_set = set(old_lines)
|
| 466 |
+
new_set = set(new_lines)
|
| 467 |
+
parts = []
|
| 468 |
+
for line in new_lines:
|
| 469 |
+
esc = _esc(line)
|
| 470 |
+
if line not in old_set:
|
| 471 |
+
parts.append(f'<div class="diff-add">+ {esc}</div>')
|
| 472 |
+
else:
|
| 473 |
+
parts.append(f'<div class="diff-same"> {esc}</div>')
|
| 474 |
+
for line in old_lines:
|
| 475 |
+
if line not in new_set:
|
| 476 |
+
esc = _esc(line)
|
| 477 |
+
parts.append(f'<div class="diff-remove">- {esc}</div>')
|
| 478 |
+
return "\n".join(parts) if parts else '<div class="diff-same">(no change)</div>'
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
@app.get("/learnings", response_class=HTMLResponse)
|
| 482 |
+
async def learnings_page():
|
| 483 |
+
history = _strategy_history()
|
| 484 |
+
results = _load("results.json", [])
|
| 485 |
+
# Index evaluations by match name for quick lookup
|
| 486 |
+
evals = {r.get("match", ""): r for r in results if r.get("evaluation")}
|
| 487 |
+
|
| 488 |
+
DIFF_CSS = """
|
| 489 |
+
<style>
|
| 490 |
+
.diff-add { color: #4caf50; background: #0d1f0d; padding: 1px 6px; white-space: pre-wrap; word-break: break-word; }
|
| 491 |
+
.diff-remove { color: #f44336; background: #1f0d0d; padding: 1px 6px; white-space: pre-wrap; word-break: break-word; }
|
| 492 |
+
.diff-same { color: #444; padding: 1px 6px; white-space: pre-wrap; word-break: break-word; }
|
| 493 |
+
.learning-card { background: #111; border: 1px solid #2a2a2a; border-radius: 6px; margin-bottom: 24px; overflow: hidden; }
|
| 494 |
+
.learning-header { background: #161616; border-bottom: 1px solid #222; padding: 10px 16px; display: flex; justify-content: space-between; align-items: baseline; }
|
| 495 |
+
.learning-match { font-size: 15px; font-weight: bold; color: #fff; }
|
| 496 |
+
.learning-date { font-size: 11px; color: #555; }
|
| 497 |
+
.learning-body { padding: 14px 16px; }
|
| 498 |
+
.eval-box { background: #0a0a0a; border: 1px solid #1e1e1e; border-radius: 4px; padding: 10px 12px; font-size: 12px; color: #888; white-space: pre-wrap; max-height: 220px; overflow-y: auto; margin-bottom: 12px; }
|
| 499 |
+
.diff-box { background: #050505; border: 1px solid #1e1e1e; border-radius: 4px; padding: 8px 4px; font-size: 12px; font-family: monospace; max-height: 320px; overflow-y: auto; }
|
| 500 |
+
.section-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; margin-top: 12px; }
|
| 501 |
+
</style>"""
|
| 502 |
+
|
| 503 |
+
body = DIFF_CSS
|
| 504 |
+
|
| 505 |
+
if not history:
|
| 506 |
+
body += '<div style="color:#444;font-style:italic;padding:32px 0;">No strategy history yet — predictions are still being reflected.</div>'
|
| 507 |
+
return _page(body, "Learnings — World Cup Predictions")
|
| 508 |
+
|
| 509 |
+
# Show newest first, skip the init commit if it has no match in results
|
| 510 |
+
reflect_entries = [e for e in reversed(history) if e["subject"].startswith("reflect:")]
|
| 511 |
+
|
| 512 |
+
if not reflect_entries:
|
| 513 |
+
body += '<div style="color:#444;font-style:italic;padding:32px 0;">No reflections recorded yet.</div>'
|
| 514 |
+
return _page(body, "Learnings — World Cup Predictions")
|
| 515 |
+
|
| 516 |
+
cards = ""
|
| 517 |
+
for e in reflect_entries:
|
| 518 |
+
match_name = e["match"]
|
| 519 |
+
date_str = e["date"].strftime("%Y-%m-%d %H:%M UTC") if e["date"] else ""
|
| 520 |
+
result_entry = evals.get(match_name, {})
|
| 521 |
+
|
| 522 |
+
# Outcome badge
|
| 523 |
+
if result_entry.get("correct") is True:
|
| 524 |
+
badge = '<span class="badge correct">✓ CORRECT</span>'
|
| 525 |
+
elif result_entry.get("correct") is False:
|
| 526 |
+
badge = '<span class="badge incorrect">✗ INCORRECT</span>'
|
| 527 |
+
else:
|
| 528 |
+
badge = ""
|
| 529 |
+
|
| 530 |
+
# Score
|
| 531 |
+
res = result_entry.get("result")
|
| 532 |
+
score_html = ""
|
| 533 |
+
if res:
|
| 534 |
+
score_html = f'<div style="font-size:12px;color:#666;margin-bottom:10px;">{_esc(result_entry.get("home",""))} {res["home_score"]}–{res["away_score"]} {_esc(result_entry.get("away",""))}</div>'
|
| 535 |
+
|
| 536 |
+
# Evaluation
|
| 537 |
+
eval_text = result_entry.get("evaluation", "")
|
| 538 |
+
eval_html = ""
|
| 539 |
+
if eval_text:
|
| 540 |
+
eval_html = f'<div class="section-label">Evaluator assessment</div><div class="eval-box">{_esc(eval_text)}</div>'
|
| 541 |
+
|
| 542 |
+
# Strategy diff
|
| 543 |
+
diff_html = _text_diff_html(e["prev_content"], e["content"])
|
| 544 |
+
is_first = not e["prev_content"].strip()
|
| 545 |
+
diff_label = "Initial strategy written" if is_first else "Strategy change"
|
| 546 |
+
|
| 547 |
+
cards += f"""<div class="learning-card">
|
| 548 |
+
<div class="learning-header">
|
| 549 |
+
<div>
|
| 550 |
+
<div class="learning-match">{_esc(match_name)}</div>
|
| 551 |
+
<div class="learning-date">{date_str}</div>
|
| 552 |
+
</div>
|
| 553 |
+
{badge}
|
| 554 |
+
</div>
|
| 555 |
+
<div class="learning-body">
|
| 556 |
+
{score_html}
|
| 557 |
+
{eval_html}
|
| 558 |
+
<div class="section-label">{diff_label}</div>
|
| 559 |
+
<div class="diff-box">{diff_html}</div>
|
| 560 |
+
</div>
|
| 561 |
+
</div>"""
|
| 562 |
+
|
| 563 |
+
body += f'<div class="section"><h2>Strategy Evolution — {len(reflect_entries)} reflection{"s" if len(reflect_entries) != 1 else ""}</h2>{cards}</div>'
|
| 564 |
+
return _page(body, "Learnings — World Cup Predictions")
|
| 565 |
+
|
| 566 |
+
|
| 567 |
@app.get("/api/results")
|
| 568 |
async def api_results():
|
| 569 |
return _load("results.json", [])
|
predict.py
CHANGED
|
@@ -364,10 +364,11 @@ Research data purchased:
|
|
| 364 |
Recent match history and outcomes:
|
| 365 |
{json.dumps(recent_results, indent=2) if recent_results else "(no history yet)"}
|
| 366 |
|
| 367 |
-
Analyze the match thoroughly, then end your response with EXACTLY these
|
| 368 |
PICK: [home|away|draw]
|
| 369 |
CONFIDENCE: [1-10]
|
| 370 |
-
|
|
|
|
| 371 |
|
| 372 |
log("Asking Gemini for prediction...")
|
| 373 |
try:
|
|
@@ -386,7 +387,7 @@ pick_raw = _parse(r"PICK:\s*(\w+)", full_reasoning, "home").lower()
|
|
| 386 |
pick = pick_raw if pick_raw in ("home", "away", "draw") else "home"
|
| 387 |
confidence_raw = _parse(r"CONFIDENCE:\s*(\d+)", full_reasoning, "5")
|
| 388 |
confidence = max(1, min(10, int(confidence_raw)))
|
| 389 |
-
|
| 390 |
reasoning = _parse(r"REASONING:\s*(.+)", full_reasoning, "Insufficient data for strong prediction")
|
| 391 |
|
| 392 |
log("Prediction: PICK=%s CONFIDENCE=%d", pick, confidence)
|
|
@@ -410,6 +411,7 @@ entry = {
|
|
| 410 |
"prediction": {
|
| 411 |
"pick": pick,
|
| 412 |
"confidence": confidence,
|
|
|
|
| 413 |
"reasoning": reasoning,
|
| 414 |
},
|
| 415 |
"full_reasoning": full_reasoning,
|
|
|
|
| 364 |
Recent match history and outcomes:
|
| 365 |
{json.dumps(recent_results, indent=2) if recent_results else "(no history yet)"}
|
| 366 |
|
| 367 |
+
Analyze the match thoroughly, then end your response with EXACTLY these 5 lines:
|
| 368 |
PICK: [home|away|draw]
|
| 369 |
CONFIDENCE: [1-10]
|
| 370 |
+
CONFIDENCE_REASON: [one sentence explaining why confidence is that specific number — what raises or lowers it]
|
| 371 |
+
REASONING: [one sentence summarizing the key factor driving the pick]"""
|
| 372 |
|
| 373 |
log("Asking Gemini for prediction...")
|
| 374 |
try:
|
|
|
|
| 387 |
pick = pick_raw if pick_raw in ("home", "away", "draw") else "home"
|
| 388 |
confidence_raw = _parse(r"CONFIDENCE:\s*(\d+)", full_reasoning, "5")
|
| 389 |
confidence = max(1, min(10, int(confidence_raw)))
|
| 390 |
+
confidence_reason = _parse(r"CONFIDENCE_REASON:\s*(.+)", full_reasoning, "")
|
| 391 |
reasoning = _parse(r"REASONING:\s*(.+)", full_reasoning, "Insufficient data for strong prediction")
|
| 392 |
|
| 393 |
log("Prediction: PICK=%s CONFIDENCE=%d", pick, confidence)
|
|
|
|
| 411 |
"prediction": {
|
| 412 |
"pick": pick,
|
| 413 |
"confidence": confidence,
|
| 414 |
+
"confidence_reason": confidence_reason,
|
| 415 |
"reasoning": reasoning,
|
| 416 |
},
|
| 417 |
"full_reasoning": full_reasoning,
|