Spaces:
Runtime error
Runtime error
World Cup Agent commited on
Commit ·
88b45ef
1
Parent(s): 238f6db
fix: remove jinja2, render html directly
Browse files
app.py
CHANGED
|
@@ -1,136 +1,281 @@
|
|
| 1 |
"""World Cup Prediction Agent — Web UI.
|
| 2 |
|
| 3 |
-
FastAPI app deployed on
|
| 4 |
-
Start with: uvicorn app:app --host 0.0.0.0 --port $PORT
|
| 5 |
"""
|
| 6 |
|
| 7 |
import json
|
| 8 |
-
import re
|
| 9 |
from datetime import datetime, timezone
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
from fastapi import FastAPI, HTTPException
|
| 13 |
from fastapi.responses import HTMLResponse
|
| 14 |
-
from fastapi.templating import Jinja2Templates
|
| 15 |
-
from starlette.requests import Request
|
| 16 |
|
| 17 |
BASE = Path(__file__).parent
|
| 18 |
-
templates = Jinja2Templates(directory=str(BASE / "templates"))
|
| 19 |
-
|
| 20 |
app = FastAPI(title="World Cup Prediction Agent")
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
def
|
| 24 |
-
p = BASE /
|
| 25 |
if not p.exists():
|
| 26 |
-
return
|
| 27 |
try:
|
| 28 |
return json.loads(p.read_text())
|
| 29 |
except Exception:
|
| 30 |
-
return
|
| 31 |
-
|
| 32 |
|
| 33 |
-
def
|
| 34 |
-
p = BASE /
|
| 35 |
return p.read_text() if p.exists() else ""
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
def
|
| 39 |
-
p = BASE / "schedule.json"
|
| 40 |
-
if not p.exists():
|
| 41 |
-
return []
|
| 42 |
-
try:
|
| 43 |
-
return json.loads(p.read_text())
|
| 44 |
-
except Exception:
|
| 45 |
-
return []
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _record_stats(results: list[dict]) -> dict:
|
| 49 |
-
completed = [r for r in results if r.get("correct") is not None]
|
| 50 |
-
wins = sum(1 for r in completed if r.get("correct"))
|
| 51 |
-
total = len(completed)
|
| 52 |
-
return {
|
| 53 |
-
"total": total,
|
| 54 |
-
"wins": wins,
|
| 55 |
-
"losses": total - wins,
|
| 56 |
-
"pct": f"{wins/total*100:.0f}%" if total else "—",
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _upcoming(schedule: list[dict], results: list[dict]) -> list[dict]:
|
| 61 |
now = datetime.now(timezone.utc)
|
| 62 |
predicted = {(r["home"], r["away"]) for r in results}
|
| 63 |
-
|
| 64 |
for f in schedule:
|
| 65 |
k = f.get("kickoff_utc", "")
|
| 66 |
-
if not k:
|
| 67 |
-
continue
|
| 68 |
try:
|
| 69 |
dt = datetime.fromisoformat(k.replace("Z", "+00:00"))
|
| 70 |
-
except
|
| 71 |
continue
|
| 72 |
if dt > now and (f["home"], f["away"]) not in predicted:
|
| 73 |
-
|
| 74 |
-
return sorted(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
@app.get("/", response_class=HTMLResponse)
|
| 78 |
-
async def dashboard(
|
| 79 |
-
results = list(reversed(
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
"
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
@app.get("/match/{match_key}", response_class=HTMLResponse)
|
| 97 |
-
async def match_detail(
|
| 98 |
home, _, away = match_key.partition("_")
|
| 99 |
home, away = home.upper(), away.upper()
|
| 100 |
-
results =
|
| 101 |
-
entry = next(
|
| 102 |
-
(r for r in reversed(results) if r.get("home") == home and r.get("away") == away),
|
| 103 |
-
None,
|
| 104 |
-
)
|
| 105 |
if not entry:
|
| 106 |
raise HTTPException(status_code=404, detail=f"No prediction found for {home} vs {away}")
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
@app.get("/strategy", response_class=HTMLResponse)
|
| 114 |
-
async def strategy_page(
|
| 115 |
-
strategy =
|
| 116 |
-
results =
|
| 117 |
-
history = [
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
"
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
@app.get("/api/results")
|
| 130 |
async def api_results():
|
| 131 |
-
return
|
| 132 |
-
|
| 133 |
|
| 134 |
@app.get("/api/schedule")
|
| 135 |
async def api_schedule():
|
| 136 |
-
return
|
|
|
|
| 1 |
"""World Cup Prediction Agent — Web UI.
|
| 2 |
|
| 3 |
+
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 |
|
| 10 |
from fastapi import FastAPI, HTTPException
|
| 11 |
from fastapi.responses import HTMLResponse
|
|
|
|
|
|
|
| 12 |
|
| 13 |
BASE = Path(__file__).parent
|
|
|
|
|
|
|
| 14 |
app = FastAPI(title="World Cup Prediction Agent")
|
| 15 |
|
| 16 |
+
CSS = """
|
| 17 |
+
<style>
|
| 18 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 19 |
+
body { background: #0d0d0d; color: #e0e0e0; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; }
|
| 20 |
+
a { color: #4dabf7; text-decoration: none; }
|
| 21 |
+
a:hover { text-decoration: underline; }
|
| 22 |
+
.container { max-width: 900px; margin: 0 auto; padding: 24px 16px; }
|
| 23 |
+
header { border-bottom: 1px solid #333; padding-bottom: 16px; margin-bottom: 24px; }
|
| 24 |
+
header h1 { font-size: 20px; color: #fff; }
|
| 25 |
+
.meta { display: flex; gap: 24px; margin-top: 8px; color: #888; font-size: 12px; }
|
| 26 |
+
.meta span { color: #e0e0e0; }
|
| 27 |
+
h2 { font-size: 13px; color: #aaa; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; border-bottom: 1px solid #222; padding-bottom: 6px; }
|
| 28 |
+
.section { margin-bottom: 32px; }
|
| 29 |
+
.match-card { background: #111; border: 1px solid #2a2a2a; border-radius: 6px; margin-bottom: 16px; overflow: hidden; }
|
| 30 |
+
.match-header { padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; background: #161616; border-bottom: 1px solid #222; }
|
| 31 |
+
.match-title { font-size: 15px; font-weight: bold; color: #fff; }
|
| 32 |
+
.match-time { font-size: 11px; color: #555; }
|
| 33 |
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: bold; }
|
| 34 |
+
.badge.correct { background: #1a3a1a; color: #4caf50; border: 1px solid #4caf50; }
|
| 35 |
+
.badge.incorrect { background: #3a1a1a; color: #f44336; border: 1px solid #f44336; }
|
| 36 |
+
.badge.pending { background: #1a1a3a; color: #888; border: 1px solid #444; }
|
| 37 |
+
.match-body { padding: 12px 16px; }
|
| 38 |
+
.pred-row { display: flex; gap: 24px; margin-bottom: 10px; flex-wrap: wrap; }
|
| 39 |
+
.pred-item .label { font-size: 10px; color: #555; text-transform: uppercase; }
|
| 40 |
+
.pred-item .value { font-size: 14px; color: #e0e0e0; }
|
| 41 |
+
.pred-item .value.pick { font-size: 16px; font-weight: bold; color: #fff; }
|
| 42 |
+
.reasoning { font-size: 12px; color: #888; font-style: italic; margin-bottom: 10px; }
|
| 43 |
+
.x402-label { font-size: 10px; color: #555; text-transform: uppercase; margin: 8px 0 4px; }
|
| 44 |
+
table.x402 { width: 100%; border-collapse: collapse; font-size: 12px; }
|
| 45 |
+
table.x402 th { text-align: left; color: #555; font-weight: normal; padding: 3px 6px; border-bottom: 1px solid #222; }
|
| 46 |
+
table.x402 td { padding: 4px 6px; color: #aaa; border-bottom: 1px solid #1a1a1a; vertical-align: top; }
|
| 47 |
+
table.x402 td.url { color: #4dabf7; font-size: 11px; word-break: break-all; max-width: 300px; }
|
| 48 |
+
table.x402 td.cost { color: #f9a825; white-space: nowrap; }
|
| 49 |
+
.no-data { font-size: 12px; color: #444; font-style: italic; }
|
| 50 |
+
.result-bar { margin-top: 10px; padding: 8px 12px; border-radius: 4px; font-size: 13px; }
|
| 51 |
+
.result-bar.correct { background: #0d1f0d; border-left: 3px solid #4caf50; }
|
| 52 |
+
.result-bar.incorrect { background: #1f0d0d; border-left: 3px solid #f44336; }
|
| 53 |
+
.result-score { font-weight: bold; color: #fff; }
|
| 54 |
+
.eval-text { font-size: 12px; color: #666; margin-top: 6px; }
|
| 55 |
+
.detail-link { font-size: 11px; color: #555; margin-top: 8px; display: block; }
|
| 56 |
+
.upcoming-item { background: #161616; border: 1px solid #2a2a2a; border-radius: 4px; padding: 8px 12px; display: flex; justify-content: space-between; margin-bottom: 6px; }
|
| 57 |
+
.strategy-box { background: #111; border: 1px solid #2a2a2a; border-radius: 6px; padding: 16px; }
|
| 58 |
+
pre { white-space: pre-wrap; word-wrap: break-word; color: #bbb; font-size: 13px; }
|
| 59 |
+
.full-text { font-size: 12px; color: #aaa; white-space: pre-wrap; background: #0a0a0a; border: 1px solid #1e1e1e; border-radius: 4px; padding: 12px; max-height: 400px; overflow-y: auto; }
|
| 60 |
+
.nav { margin-bottom: 20px; font-size: 13px; }
|
| 61 |
+
</style>
|
| 62 |
+
"""
|
| 63 |
|
| 64 |
+
def _load(filename, default):
|
| 65 |
+
p = BASE / filename
|
| 66 |
if not p.exists():
|
| 67 |
+
return default
|
| 68 |
try:
|
| 69 |
return json.loads(p.read_text())
|
| 70 |
except Exception:
|
| 71 |
+
return default
|
|
|
|
| 72 |
|
| 73 |
+
def _load_text(filename):
|
| 74 |
+
p = BASE / filename
|
| 75 |
return p.read_text() if p.exists() else ""
|
| 76 |
|
| 77 |
+
def _stats(results):
|
| 78 |
+
done = [r for r in results if r.get("correct") is not None]
|
| 79 |
+
w = sum(1 for r in done if r.get("correct"))
|
| 80 |
+
t = len(done)
|
| 81 |
+
return w, t - w, f"{w/t*100:.0f}%" if t else "—"
|
| 82 |
|
| 83 |
+
def _upcoming(schedule, results):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
now = datetime.now(timezone.utc)
|
| 85 |
predicted = {(r["home"], r["away"]) for r in results}
|
| 86 |
+
out = []
|
| 87 |
for f in schedule:
|
| 88 |
k = f.get("kickoff_utc", "")
|
|
|
|
|
|
|
| 89 |
try:
|
| 90 |
dt = datetime.fromisoformat(k.replace("Z", "+00:00"))
|
| 91 |
+
except Exception:
|
| 92 |
continue
|
| 93 |
if dt > now and (f["home"], f["away"]) not in predicted:
|
| 94 |
+
out.append((dt, f))
|
| 95 |
+
return [f for _, f in sorted(out, key=lambda x: x[0])[:8]]
|
| 96 |
+
|
| 97 |
+
def _badge(r):
|
| 98 |
+
if r.get("correct") is True:
|
| 99 |
+
return '<span class="badge correct">✓ CORRECT</span>'
|
| 100 |
+
if r.get("correct") is False:
|
| 101 |
+
return '<span class="badge incorrect">✗ INCORRECT</span>'
|
| 102 |
+
return '<span class="badge pending">PENDING</span>'
|
| 103 |
+
|
| 104 |
+
def _esc(s):
|
| 105 |
+
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 106 |
+
|
| 107 |
+
def _page(body, title="⚽ World Cup Prediction Agent"):
|
| 108 |
+
results = _load("results.json", [])
|
| 109 |
+
w, l, pct = _stats(results)
|
| 110 |
+
nav = '<div class="nav"><a href="/">Dashboard</a> · <a href="/strategy">Strategy</a></div>'
|
| 111 |
+
header = f"""
|
| 112 |
+
<header>
|
| 113 |
+
<h1>⚽ World Cup Prediction Agent</h1>
|
| 114 |
+
<div class="meta">
|
| 115 |
+
<div>Record: <span>{w}W–{l}L ({pct})</span></div>
|
| 116 |
+
<div>Matches predicted: <span>{len(results)}</span></div>
|
| 117 |
+
</div>
|
| 118 |
+
</header>"""
|
| 119 |
+
return HTMLResponse(f"<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{title}</title>{CSS}</head><body><div class='container'>{header}{nav}{body}</div></body></html>")
|
| 120 |
|
| 121 |
|
| 122 |
@app.get("/", response_class=HTMLResponse)
|
| 123 |
+
async def dashboard():
|
| 124 |
+
results = list(reversed(_load("results.json", [])))
|
| 125 |
+
schedule = _load("schedule.json", [])
|
| 126 |
+
strategy = _load_text("strategy.md")
|
| 127 |
+
upcoming = _upcoming(schedule, _load("results.json", []))
|
| 128 |
+
|
| 129 |
+
html = ""
|
| 130 |
+
|
| 131 |
+
# Upcoming
|
| 132 |
+
if upcoming:
|
| 133 |
+
items = ""
|
| 134 |
+
for f in upcoming:
|
| 135 |
+
items += f"""<div class="upcoming-item">
|
| 136 |
+
<div><b>{_esc(f['home'])} vs {_esc(f['away'])}</b> <span style="color:#555;font-size:11px;margin-left:8px;">{_esc(f.get('stage',''))}</span></div>
|
| 137 |
+
<div style="color:#666;font-size:12px;">{f['kickoff_utc'][:16].replace('T',' ')} UTC</div>
|
| 138 |
+
</div>"""
|
| 139 |
+
html += f'<div class="section"><h2>Upcoming</h2>{items}</div>'
|
| 140 |
+
|
| 141 |
+
# Past matches
|
| 142 |
+
if results:
|
| 143 |
+
cards = ""
|
| 144 |
+
for r in results:
|
| 145 |
+
pred = r.get("prediction", {})
|
| 146 |
+
res = r.get("result")
|
| 147 |
+
used = r.get("services_used", [])
|
| 148 |
+
planned = r.get("services_planned", [])
|
| 149 |
+
|
| 150 |
+
x402_html = ""
|
| 151 |
+
if used:
|
| 152 |
+
rows = "".join(f'<tr><td class="url">{_esc(s["source"])}</td><td class="cost">${s["cost"]:.4f}</td><td>{_esc(s.get("reason",""))}</td></tr>' for s in used)
|
| 153 |
+
x402_html = f'<div class="x402-label">x402 data purchased</div><table class="x402"><tr><th>Endpoint</th><th>Cost</th><th>Reason</th></tr>{rows}</table>'
|
| 154 |
+
elif planned:
|
| 155 |
+
x402_html = f'<div class="no-data">{len(planned)} endpoint(s) considered, none purchased.</div>'
|
| 156 |
+
else:
|
| 157 |
+
x402_html = '<div class="no-data">No x402 data for this match.</div>'
|
| 158 |
+
|
| 159 |
+
result_html = ""
|
| 160 |
+
if res:
|
| 161 |
+
cls = "correct" if r.get("correct") else "incorrect"
|
| 162 |
+
eval_snippet = _esc((r.get("evaluation") or "")[:180])
|
| 163 |
+
result_html = f'''<div class="result-bar {cls}">
|
| 164 |
+
Result: <span class="result-score">{_esc(r["home"])} {res["home_score"]}–{res["away_score"]} {_esc(r["away"])}</span>
|
| 165 |
+
{f'<div class="eval-text">{eval_snippet}{"…" if len(r.get("evaluation",""))>180 else ""}</div>' if eval_snippet else ""}
|
| 166 |
+
</div>'''
|
| 167 |
+
|
| 168 |
+
cards += f"""<div class="match-card">
|
| 169 |
+
<div class="match-header">
|
| 170 |
+
<div>
|
| 171 |
+
<div class="match-title">{_esc(r["match"])}</div>
|
| 172 |
+
<div class="match-time">{r.get("kickoff","")[:16].replace("T"," ")} UTC</div>
|
| 173 |
+
</div>
|
| 174 |
+
{_badge(r)}
|
| 175 |
+
</div>
|
| 176 |
+
<div class="match-body">
|
| 177 |
+
<div class="pred-row">
|
| 178 |
+
<div class="pred-item"><div class="label">Pick</div><div class="value pick">{_esc(pred.get("pick","?")).upper()}</div></div>
|
| 179 |
+
<div class="pred-item"><div class="label">Confidence</div><div class="value">{pred.get("confidence","?")}/10</div></div>
|
| 180 |
+
<div class="pred-item"><div class="label">Bet</div><div class="value">${pred.get("bet","?")}</div></div>
|
| 181 |
+
<div class="pred-item"><div class="label">Research cost</div><div class="value" style="color:#f9a825;">${r.get("research_cost",0):.4f}</div></div>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="reasoning">"{_esc(pred.get("reasoning",""))}"</div>
|
| 184 |
+
{x402_html}
|
| 185 |
+
{result_html}
|
| 186 |
+
<a href="/match/{_esc(r['home'])}_{_esc(r['away'])}" class="detail-link">Full reasoning & reflection →</a>
|
| 187 |
+
</div>
|
| 188 |
+
</div>"""
|
| 189 |
+
html += f'<div class="section"><h2>Predictions</h2>{cards}</div>'
|
| 190 |
+
else:
|
| 191 |
+
html += '<div style="color:#444;font-style:italic;padding:32px 0;">No predictions yet.</div>'
|
| 192 |
+
|
| 193 |
+
# Strategy snippet
|
| 194 |
+
if strategy:
|
| 195 |
+
snippet = _esc(strategy[:500])
|
| 196 |
+
more = ' <a href="/strategy">… read more</a>' if len(strategy) > 500 else ""
|
| 197 |
+
html += f'<div class="section"><h2>Current Strategy <a href="/strategy" style="font-size:11px;color:#555;font-weight:normal;margin-left:8px;">full history →</a></h2><div class="strategy-box"><pre>{snippet}{more}</pre></div></div>'
|
| 198 |
+
|
| 199 |
+
return _page(html)
|
| 200 |
|
| 201 |
|
| 202 |
@app.get("/match/{match_key}", response_class=HTMLResponse)
|
| 203 |
+
async def match_detail(match_key: str):
|
| 204 |
home, _, away = match_key.partition("_")
|
| 205 |
home, away = home.upper(), away.upper()
|
| 206 |
+
results = _load("results.json", [])
|
| 207 |
+
entry = next((r for r in reversed(results) if r.get("home") == home and r.get("away") == away), None)
|
|
|
|
|
|
|
|
|
|
| 208 |
if not entry:
|
| 209 |
raise HTTPException(status_code=404, detail=f"No prediction found for {home} vs {away}")
|
| 210 |
+
|
| 211 |
+
pred = entry.get("prediction", {})
|
| 212 |
+
res = entry.get("result")
|
| 213 |
+
used = entry.get("services_used", [])
|
| 214 |
+
planned = entry.get("services_planned", [])
|
| 215 |
+
|
| 216 |
+
result_html = ""
|
| 217 |
+
if res:
|
| 218 |
+
cls = "correct" if entry.get("correct") else "incorrect"
|
| 219 |
+
result_html = f'<div class="result-bar {cls}" style="margin-bottom:16px;"><span class="result-score">{_esc(home)} {res["home_score"]}–{res["away_score"]} {_esc(away)}</span> {_badge(entry)}</div>'
|
| 220 |
+
|
| 221 |
+
used_urls = {s["source"] for s in used}
|
| 222 |
+
plan_rows = ""
|
| 223 |
+
for s in planned:
|
| 224 |
+
status = '✓ bought' if s["url"] in used_urls else '<span style="color:#555">skipped</span>'
|
| 225 |
+
plan_rows += f'<tr><td class="url">{_esc(s["url"])}</td><td class="cost">${s.get("cost",0):.4f}</td><td>{status}</td><td>{_esc(s.get("reason",""))}</td></tr>'
|
| 226 |
+
|
| 227 |
+
x402_section = ""
|
| 228 |
+
if planned:
|
| 229 |
+
x402_section = f'<div class="section"><h2>x402 Research — ${entry.get("research_cost",0):.4f} spent</h2><table class="x402"><tr><th>Endpoint</th><th>Cost</th><th>Status</th><th>Reason</th></tr>{plan_rows}</table></div>'
|
| 230 |
+
|
| 231 |
+
eval_html = f'<div class="section"><h2>Reflection & Evaluation</h2><div class="full-text">{_esc(entry.get("evaluation","(not yet reflected)"))}</div></div>' if entry.get("evaluation") else ""
|
| 232 |
+
snapshot_html = f'<div class="section"><h2>Strategy at Prediction Time</h2><div class="full-text">{_esc(entry.get("strategy_snapshot","(empty)"))}</div></div>' if entry.get("strategy_snapshot") is not None else ""
|
| 233 |
+
|
| 234 |
+
body = f"""
|
| 235 |
+
<div style="margin-bottom:12px;"><a href="/" style="color:#555;font-size:12px;">← Back</a></div>
|
| 236 |
+
<h2 style="font-size:18px;color:#fff;border:none;margin-bottom:8px;">{_esc(entry["match"])}</h2>
|
| 237 |
+
<div style="color:#555;font-size:12px;margin-bottom:16px;">{entry.get("kickoff","")[:16].replace("T"," ")} UTC</div>
|
| 238 |
+
{result_html}
|
| 239 |
+
<div class="section"><h2>Prediction</h2>
|
| 240 |
+
<div class="pred-row">
|
| 241 |
+
<div class="pred-item"><div class="label">Pick</div><div class="value pick">{_esc(pred.get("pick","?")).upper()}</div></div>
|
| 242 |
+
<div class="pred-item"><div class="label">Confidence</div><div class="value">{pred.get("confidence","?")}/10</div></div>
|
| 243 |
+
<div class="pred-item"><div class="label">Bet</div><div class="value">${pred.get("bet","?")}</div></div>
|
| 244 |
+
</div>
|
| 245 |
+
<div class="reasoning">"{_esc(pred.get("reasoning",""))}"</div>
|
| 246 |
+
</div>
|
| 247 |
+
{x402_section}
|
| 248 |
+
<div class="section"><h2>Full Gemini Reasoning</h2><div class="full-text">{_esc(entry.get("full_reasoning","(none)"))}</div></div>
|
| 249 |
+
{eval_html}
|
| 250 |
+
{snapshot_html}"""
|
| 251 |
+
|
| 252 |
+
return _page(body, f"{home} vs {away} — World Cup Predictions")
|
| 253 |
|
| 254 |
|
| 255 |
@app.get("/strategy", response_class=HTMLResponse)
|
| 256 |
+
async def strategy_page():
|
| 257 |
+
strategy = _load_text("strategy.md")
|
| 258 |
+
results = _load("results.json", [])
|
| 259 |
+
history = [(r.get("match"), r.get("kickoff","")[:10], r.get("strategy_snapshot","")) for r in results if r.get("strategy_snapshot") is not None]
|
| 260 |
+
|
| 261 |
+
current = f'<div class="strategy-box"><pre>{_esc(strategy) if strategy else "(no strategy yet)"}</pre></div>'
|
| 262 |
+
|
| 263 |
+
hist_html = ""
|
| 264 |
+
if history:
|
| 265 |
+
items = ""
|
| 266 |
+
for match, date, snap in reversed(history):
|
| 267 |
+
snippet = _esc((snap or "")[:300])
|
| 268 |
+
items += f'<div style="border-bottom:1px solid #1a1a1a;padding:10px 0;"><div style="font-size:12px;color:#888;margin-bottom:4px;">After {_esc(match)} ({date})</div><div style="font-size:11px;color:#555;">{snippet}{"…" if len(snap or "")>300 else ""}</div></div>'
|
| 269 |
+
hist_html = f'<div class="section"><h2>Strategy Evolution</h2>{items}</div>'
|
| 270 |
+
|
| 271 |
+
body = f'<div class="section"><h2>Current Strategy</h2>{current}</div>{hist_html}'
|
| 272 |
+
return _page(body, "Strategy — World Cup Predictions")
|
| 273 |
|
| 274 |
|
| 275 |
@app.get("/api/results")
|
| 276 |
async def api_results():
|
| 277 |
+
return _load("results.json", [])
|
|
|
|
| 278 |
|
| 279 |
@app.get("/api/schedule")
|
| 280 |
async def api_schedule():
|
| 281 |
+
return _load("schedule.json", [])
|