World Cup Agent commited on
Commit
d6b171a
·
1 Parent(s): 6797e3f

feat: confidence hover tooltip with CONFIDENCE_REASON field

Browse files
Files changed (2) hide show
  1. app.py +152 -3
  2. 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 4 lines:
368
  PICK: [home|away|draw]
369
  CONFIDENCE: [1-10]
370
- REASONING: [one sentence max]"""
 
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
- try:
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,