Spaces:
Running
Running
Sync from GitHub via hub-sync
Browse files- docs/DP.md +52 -2
- docs/PRD.md +24 -5
- run.py +36 -7
- services/dashboard_data.py +21 -0
- services/track_record.py +149 -0
- tests/test_track_record.py +170 -0
- ui/i18n.py +27 -3
- ui/tabs/accuracy.py +196 -131
docs/DP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Design Proposal (DP) — forecaster-agent
|
| 2 |
|
| 3 |
-
**Version:** 0.
|
| 4 |
**Last updated:** 2026-06-24
|
| 5 |
|
| 6 |
Architectural design implementing [PRD.md](./PRD.md) under Harness Engineering standards.
|
|
@@ -224,7 +224,7 @@ CI: `.github/workflows/ci.yml` — Python 3.11 + 3.12 matrix.
|
|
| 224 |
|
| 225 |
| Item | Priority | Status |
|
| 226 |
|------|----------|--------|
|
| 227 |
-
| `dashboard.py` monolith (~1150 LOC) | P1 |
|
| 228 |
| Discord/Telegram crowd bots | P2 | ✅ `bots/` |
|
| 229 |
| No `MockLLMClient` for offline `run.py once` | P2 | open |
|
| 230 |
| `src/` package layout + Poetry lock | P3 | open |
|
|
@@ -309,6 +309,56 @@ This closes the perception gap: users know their survey answers matter.
|
|
| 309 |
|
| 310 |
---
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
## 11. Security & compliance defaults
|
| 313 |
|
| 314 |
- `require_review: true` — never change default in repo
|
|
|
|
| 1 |
# Design Proposal (DP) — forecaster-agent
|
| 2 |
|
| 3 |
+
**Version:** 0.8 (Live Track Record Credibility)
|
| 4 |
**Last updated:** 2026-06-24
|
| 5 |
|
| 6 |
Architectural design implementing [PRD.md](./PRD.md) under Harness Engineering standards.
|
|
|
|
| 224 |
|
| 225 |
| Item | Priority | Status |
|
| 226 |
|------|----------|--------|
|
| 227 |
+
| `dashboard.py` monolith (~1150 LOC) | P1 | ✅ 104-LOC orchestrator + `ui/tabs/*` |
|
| 228 |
| Discord/Telegram crowd bots | P2 | ✅ `bots/` |
|
| 229 |
| No `MockLLMClient` for offline `run.py once` | P2 | open |
|
| 230 |
| `src/` package layout + Poetry lock | P3 | open |
|
|
|
|
| 309 |
|
| 310 |
---
|
| 311 |
|
| 312 |
+
## 12. v0.8 Design additions (Live Track Record Credibility)
|
| 313 |
+
|
| 314 |
+
### 12.1 Origin classification (HR-11)
|
| 315 |
+
|
| 316 |
+
Location: `services/track_record.py`
|
| 317 |
+
|
| 318 |
+
| Function | Purpose |
|
| 319 |
+
|----------|---------|
|
| 320 |
+
| `seed_prediction_ids(seed_path)` | Load fingerprint set from `predictions_seed.json` |
|
| 321 |
+
| `prediction_origin(p, seed_ids)` | Returns `"seed"` if `p.id` ∈ seed_ids else `"live"` |
|
| 322 |
+
| `partition_by_origin(preds, seed_ids)` | Split into `(seed_preds, live_preds)` |
|
| 323 |
+
| `scoreboard_subset(preds)` | Same shape as `Registry.scoreboard()` for a filtered list |
|
| 324 |
+
| `upcoming_resolutions(preds, *, limit=10)` | Open/due preds sorted by `resolution_date` asc |
|
| 325 |
+
|
| 326 |
+
**Rule:** seed IDs are computed once per dashboard render from the committed seed file;
|
| 327 |
+
live predictions are everything else in the registry (including cron-generated rows).
|
| 328 |
+
|
| 329 |
+
### 12.2 Live-only export
|
| 330 |
+
|
| 331 |
+
`run.py export` filters with `partition_by_origin` before writing JSONL.
|
| 332 |
+
Seed data never enters `predictions_live.jsonl` — it remains in `predictions_seed.json`.
|
| 333 |
+
|
| 334 |
+
### 12.3 Export verification (CI guard)
|
| 335 |
+
|
| 336 |
+
`run.py verify-export`:
|
| 337 |
+
|
| 338 |
+
1. Load live predictions from DB (`partition_by_origin`)
|
| 339 |
+
2. Load `predictions_live.jsonl`
|
| 340 |
+
3. For each live id, assert matching `status`, `outcome`, `brier` in JSONL
|
| 341 |
+
4. Exit code 1 on any mismatch (prevents silent resolve/export regression)
|
| 342 |
+
|
| 343 |
+
Called in `.github/workflows/daily-pages.yml` immediately after `export`.
|
| 344 |
+
|
| 345 |
+
### 12.4 Track Record UI layout
|
| 346 |
+
|
| 347 |
+
```
|
| 348 |
+
┌─ Curated benchmark (seed) ─────────────────┐
|
| 349 |
+
│ resolved N | mean Brier X | table │
|
| 350 |
+
└────────────────────────────────────────────┘
|
| 351 |
+
┌─ Live LLM predictions ─────────────────────┐
|
| 352 |
+
│ open N | resolved N | mean Brier Y │
|
| 353 |
+
│ ▶ Upcoming resolutions (live only) │
|
| 354 |
+
│ resolved table (live only) │
|
| 355 |
+
└────────────────────────────────────────────┘
|
| 356 |
+
```
|
| 357 |
+
|
| 358 |
+
CSV download includes `origin` column on every row.
|
| 359 |
+
|
| 360 |
+
---
|
| 361 |
+
|
| 362 |
## 11. Security & compliance defaults
|
| 363 |
|
| 364 |
- `require_review: true` — never change default in repo
|
docs/PRD.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Product Requirement Document (PRD) — forecaster-agent
|
| 2 |
|
| 3 |
-
**Version:** 0.
|
| 4 |
**Last updated:** 2026-06-24
|
| 5 |
|
| 6 |
An autonomous AI × economy forecasting system that generates, calibrates, and publishes **falsifiable** predictions — with explicit limits on historical extrapolation.
|
|
@@ -122,6 +122,7 @@ Parallel surface: `dashboard.py` (Streamlit) + `services/read_model.py` (read AP
|
|
| 122 |
| **HR-8** | BUSL-1.1 license | Commercial SaaS/API requires separate license |
|
| 123 |
| **HR-9** | Citation integrity | LLM-generated `sources` URLs pass schema check; arXiv IDs must not reference a future YYMM; `example.com` / placeholder domains rejected at parse time. Enforced in `forecast._sanitize_sources()`. |
|
| 124 |
| **HR-10** | Resolved-state durability | `run.py export` serialises `status`, `outcome`, `brier`, `resolved_at`; `ensure_demo_registry` reloads them via `model_validate_json` preserving resolution. Track record must never regress to all-`open` after a cache eviction. |
|
|
|
|
| 125 |
|
| 126 |
---
|
| 127 |
|
|
@@ -196,6 +197,22 @@ LLM accumulation run (12 predictions via Groq):
|
|
| 196 |
- [x] **ROOT_CAUSE contrastive pairs** in `predictions_seed.json` + `track_record_summary` WRONG entries show 280-char rationale window
|
| 197 |
- [x] **PR-aligned commit-back**: `predictions_live.jsonl` now correctly detects new/changed file via `git add` before `git diff --cached`
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
---
|
| 200 |
|
| 201 |
## 6. Success metrics
|
|
@@ -207,13 +224,15 @@ LLM accumulation run (12 predictions via Groq):
|
|
| 207 |
| OOD fires → confidence downshift | Qualitative audit |
|
| 208 |
| P0 bugs open | 0 |
|
| 209 |
| MCP read tools documented | Phase 3 exit |
|
| 210 |
-
|
|
| 211 |
-
|
|
| 212 |
-
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
---
|
| 215 |
|
| 216 |
-
## 7. Out of scope (v0.
|
| 217 |
|
| 218 |
- Auto-publishing with `require_review: false` as default
|
| 219 |
- Financial advice positioning
|
|
|
|
| 1 |
# Product Requirement Document (PRD) — forecaster-agent
|
| 2 |
|
| 3 |
+
**Version:** 0.8 (Live Track Record Credibility)
|
| 4 |
**Last updated:** 2026-06-24
|
| 5 |
|
| 6 |
An autonomous AI × economy forecasting system that generates, calibrates, and publishes **falsifiable** predictions — with explicit limits on historical extrapolation.
|
|
|
|
| 122 |
| **HR-8** | BUSL-1.1 license | Commercial SaaS/API requires separate license |
|
| 123 |
| **HR-9** | Citation integrity | LLM-generated `sources` URLs pass schema check; arXiv IDs must not reference a future YYMM; `example.com` / placeholder domains rejected at parse time. Enforced in `forecast._sanitize_sources()`. |
|
| 124 |
| **HR-10** | Resolved-state durability | `run.py export` serialises `status`, `outcome`, `brier`, `resolved_at`; `ensure_demo_registry` reloads them via `model_validate_json` preserving resolution. Track record must never regress to all-`open` after a cache eviction. |
|
| 125 |
+
| **HR-11** | Origin transparency | Every prediction shown in the Track Record tab carries an explicit `origin` badge (`seed` = curated benchmark, `live` = daily LLM cron). `run.py export` writes **live-only** rows to `predictions_live.jsonl`; `run.py verify-export` fails CI if DB live state diverges from the committed file after a resolve. |
|
| 126 |
|
| 127 |
---
|
| 128 |
|
|
|
|
| 197 |
- [x] **ROOT_CAUSE contrastive pairs** in `predictions_seed.json` + `track_record_summary` WRONG entries show 280-char rationale window
|
| 198 |
- [x] **PR-aligned commit-back**: `predictions_live.jsonl` now correctly detects new/changed file via `git add` before `git diff --cached`
|
| 199 |
|
| 200 |
+
### Phase 7 — Live Track Record Credibility (v0.8, 2026-06-24)
|
| 201 |
+
|
| 202 |
+
Problem: seed demo data (12 resolved) and daily LLM predictions (12 open) were merged
|
| 203 |
+
in the UI with no origin label — users could not tell curated benchmark from real agent
|
| 204 |
+
performance.
|
| 205 |
+
|
| 206 |
+
- [x] **HR-11 Origin split**: Track Record tab shows separate panels for *Curated benchmark*
|
| 207 |
+
(seed) and *Live LLM* with independent resolved counts and mean Brier
|
| 208 |
+
- [x] **Upcoming resolutions**: timeline of open live predictions sorted by
|
| 209 |
+
`resolution_date`, showing criteria so users see the loop is active
|
| 210 |
+
- [x] **CSV `origin` column**: public download includes `seed|live` for replication
|
| 211 |
+
- [x] **Live-only export**: `run.py export` writes only non-seed predictions to
|
| 212 |
+
`predictions_live.jsonl` (seed stays in `predictions_seed.json`)
|
| 213 |
+
- [x] **CI guard**: `run.py verify-export` after daily cron; fails if live DB state
|
| 214 |
+
≠ committed JSONL (catches silent resolve/export regressions)
|
| 215 |
+
|
| 216 |
---
|
| 217 |
|
| 218 |
## 6. Success metrics
|
|
|
|
| 224 |
| OOD fires → confidence downshift | Qualitative audit |
|
| 225 |
| P0 bugs open | 0 |
|
| 226 |
| MCP read tools documented | Phase 3 exit |
|
| 227 |
+
| LLM-generated sources with hallucinated arXiv IDs | 0 (HR-9) |
|
| 228 |
+
| Resolved predictions surviving cache eviction | 100% (HR-10) |
|
| 229 |
+
| `predictions_live.jsonl` committed per real-LLM cron run | ✅ (daily) |
|
| 230 |
+
| Track Record UI shows seed vs live origin | HR-11 (Phase 7) |
|
| 231 |
+
| Live resolved count visible independently of seed | Phase 7 exit |
|
| 232 |
|
| 233 |
---
|
| 234 |
|
| 235 |
+
## 7. Out of scope (v0.8)
|
| 236 |
|
| 237 |
- Auto-publishing with `require_review: false` as default
|
| 238 |
- Financial advice positioning
|
run.py
CHANGED
|
@@ -10,8 +10,9 @@
|
|
| 10 |
python run.py once --config config.ci.yaml # CI / auto-publish profile
|
| 11 |
python run.py calibrate-jobs # BLS → KB displacement risk overlay
|
| 12 |
python run.py calibrate-jobs --dry-run
|
| 13 |
-
python run.py export # dump registry →
|
| 14 |
python run.py export --out path.jsonl
|
|
|
|
| 15 |
python run.py warmup # import predictions_live.jsonl → DB (cache-miss recovery)
|
| 16 |
python run.py warmup --src path.jsonl
|
| 17 |
"""
|
|
@@ -104,17 +105,43 @@ def cmd_warmup(cfg, src_path: str = "data/predictions_live.jsonl"):
|
|
| 104 |
print(f"warmup: imported {len(added)} new prediction(s) from {src_path}")
|
| 105 |
|
| 106 |
|
| 107 |
-
def cmd_export(cfg, out_path: str = "data/predictions_live.jsonl"):
|
| 108 |
-
"""Dump
|
| 109 |
-
|
|
|
|
| 110 |
reg = Registry(cfg["database_path"])
|
| 111 |
-
|
|
|
|
|
|
|
| 112 |
path = Path(out_path)
|
| 113 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 114 |
with path.open("w", encoding="utf-8") as f:
|
| 115 |
-
for p in
|
| 116 |
f.write(p.model_dump_json() + "\n")
|
| 117 |
-
print(f"exported {len(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
def cmd_approve(cfg):
|
|
@@ -189,6 +216,8 @@ def main():
|
|
| 189 |
cmd_calibrate_jobs(cfg, dry_run=dry_run_flag)
|
| 190 |
elif cmd == "export":
|
| 191 |
cmd_export(cfg, out_path or "data/predictions_live.jsonl")
|
|
|
|
|
|
|
| 192 |
elif cmd == "warmup":
|
| 193 |
src = _extract_opt(args, "--src") or out_path or "data/predictions_live.jsonl"
|
| 194 |
cmd_warmup(cfg, src)
|
|
|
|
| 10 |
python run.py once --config config.ci.yaml # CI / auto-publish profile
|
| 11 |
python run.py calibrate-jobs # BLS → KB displacement risk overlay
|
| 12 |
python run.py calibrate-jobs --dry-run
|
| 13 |
+
python run.py export # dump live registry rows → predictions_live.jsonl
|
| 14 |
python run.py export --out path.jsonl
|
| 15 |
+
python run.py verify-export # assert DB live state == committed JSONL (HR-11)
|
| 16 |
python run.py warmup # import predictions_live.jsonl → DB (cache-miss recovery)
|
| 17 |
python run.py warmup --src path.jsonl
|
| 18 |
"""
|
|
|
|
| 105 |
print(f"warmup: imported {len(added)} new prediction(s) from {src_path}")
|
| 106 |
|
| 107 |
|
| 108 |
+
def cmd_export(cfg, out_path: str = "data/predictions_live.jsonl", seed_path: str | None = None):
|
| 109 |
+
"""Dump live-only predictions to JSONL (HR-11: seed stays in predictions_seed.json)."""
|
| 110 |
+
from services.track_record import partition_by_origin, seed_prediction_ids
|
| 111 |
+
|
| 112 |
reg = Registry(cfg["database_path"])
|
| 113 |
+
seed_ids = seed_prediction_ids(seed_path)
|
| 114 |
+
_, live = partition_by_origin(reg.load(), seed_ids)
|
| 115 |
+
live.sort(key=lambda p: (p.created_at, p.id))
|
| 116 |
path = Path(out_path)
|
| 117 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 118 |
with path.open("w", encoding="utf-8") as f:
|
| 119 |
+
for p in live:
|
| 120 |
f.write(p.model_dump_json() + "\n")
|
| 121 |
+
print(f"exported {len(live)} live prediction(s) to {out_path}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def cmd_verify_export(
|
| 125 |
+
cfg,
|
| 126 |
+
out_path: str = "data/predictions_live.jsonl",
|
| 127 |
+
seed_path: str | None = None,
|
| 128 |
+
):
|
| 129 |
+
"""Fail if committed JSONL does not mirror live predictions in the DB (HR-11)."""
|
| 130 |
+
from services.dashboard_seed import _load_live_rows
|
| 131 |
+
from services.track_record import partition_by_origin, seed_prediction_ids, verify_live_export_sync
|
| 132 |
+
|
| 133 |
+
reg = Registry(cfg["database_path"])
|
| 134 |
+
seed_ids = seed_prediction_ids(seed_path)
|
| 135 |
+
_, db_live = partition_by_origin(reg.load(), seed_ids)
|
| 136 |
+
jsonl_live = _load_live_rows(Path(out_path))
|
| 137 |
+
|
| 138 |
+
errors = verify_live_export_sync(db_live, jsonl_live)
|
| 139 |
+
if errors:
|
| 140 |
+
print("verify-export FAILED:", file=sys.stderr)
|
| 141 |
+
for err in errors:
|
| 142 |
+
print(f" - {err}", file=sys.stderr)
|
| 143 |
+
sys.exit(1)
|
| 144 |
+
print(f"verify-export OK ({len(db_live)} live prediction(s) in sync)")
|
| 145 |
|
| 146 |
|
| 147 |
def cmd_approve(cfg):
|
|
|
|
| 216 |
cmd_calibrate_jobs(cfg, dry_run=dry_run_flag)
|
| 217 |
elif cmd == "export":
|
| 218 |
cmd_export(cfg, out_path or "data/predictions_live.jsonl")
|
| 219 |
+
elif cmd == "verify-export":
|
| 220 |
+
cmd_verify_export(cfg, out_path or "data/predictions_live.jsonl")
|
| 221 |
elif cmd == "warmup":
|
| 222 |
src = _extract_opt(args, "--src") or out_path or "data/predictions_live.jsonl"
|
| 223 |
cmd_warmup(cfg, src)
|
services/dashboard_data.py
CHANGED
|
@@ -10,6 +10,12 @@ from registry import Registry
|
|
| 10 |
from schemas import Prediction
|
| 11 |
from services.config_loader import load_config
|
| 12 |
from services.read_model import get_ood_assessment, get_scoreboard, search_jobs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def get_scoreboard_data() -> dict[str, Any]:
|
|
@@ -20,6 +26,21 @@ def get_predictions() -> list[Prediction]:
|
|
| 20 |
return Registry().load()
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def build_evolution_prior(scenario: dict[str, Any], *, n_bootstrap: int | None = None) -> ev.EvolutionPrior:
|
| 24 |
cfg = load_config()
|
| 25 |
boot = n_bootstrap if n_bootstrap is not None else int(cfg.get("evolution", {}).get("n_bootstrap", 50))
|
|
|
|
| 10 |
from schemas import Prediction
|
| 11 |
from services.config_loader import load_config
|
| 12 |
from services.read_model import get_ood_assessment, get_scoreboard, search_jobs
|
| 13 |
+
from services.track_record import (
|
| 14 |
+
partition_by_origin,
|
| 15 |
+
scoreboard_subset,
|
| 16 |
+
seed_prediction_ids,
|
| 17 |
+
upcoming_resolutions,
|
| 18 |
+
)
|
| 19 |
|
| 20 |
|
| 21 |
def get_scoreboard_data() -> dict[str, Any]:
|
|
|
|
| 26 |
return Registry().load()
|
| 27 |
|
| 28 |
|
| 29 |
+
def get_track_record_views() -> dict[str, Any]:
|
| 30 |
+
"""Predictions partitioned by origin with per-origin scoreboards."""
|
| 31 |
+
preds = get_predictions()
|
| 32 |
+
seed_ids = seed_prediction_ids()
|
| 33 |
+
seed_preds, live_preds = partition_by_origin(preds, seed_ids)
|
| 34 |
+
return {
|
| 35 |
+
"seed_ids": seed_ids,
|
| 36 |
+
"seed_preds": seed_preds,
|
| 37 |
+
"live_preds": live_preds,
|
| 38 |
+
"seed_scoreboard": scoreboard_subset(seed_preds),
|
| 39 |
+
"live_scoreboard": scoreboard_subset(live_preds),
|
| 40 |
+
"upcoming_live": upcoming_resolutions(live_preds),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
def build_evolution_prior(scenario: dict[str, Any], *, n_bootstrap: int | None = None) -> ev.EvolutionPrior:
|
| 45 |
cfg = load_config()
|
| 46 |
boot = n_bootstrap if n_bootstrap is not None else int(cfg.get("evolution", {}).get("n_bootstrap", 50))
|
services/track_record.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Track-record helpers: seed vs live origin, scoreboards, export verification (HR-11)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import date, datetime, timezone
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from paths import PROJECT_ROOT
|
| 9 |
+
from schemas import Prediction, Status
|
| 10 |
+
|
| 11 |
+
Origin = Literal["seed", "live"]
|
| 12 |
+
|
| 13 |
+
_RESOLVED = (Status.resolved_true, Status.resolved_false)
|
| 14 |
+
_OPEN = (Status.open, Status.due)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def seed_prediction_ids(seed_path: str | Path | None = None) -> set[str]:
|
| 18 |
+
"""Fingerprint IDs of curated benchmark rows in predictions_seed.json."""
|
| 19 |
+
from services.dashboard_seed import _load_seed_rows
|
| 20 |
+
|
| 21 |
+
path = Path(seed_path) if seed_path else PROJECT_ROOT / "data" / "predictions_seed.json"
|
| 22 |
+
return {p.id for p in _load_seed_rows(path)}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def prediction_origin(p: Prediction, seed_ids: set[str]) -> Origin:
|
| 26 |
+
return "seed" if p.id in seed_ids else "live"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def partition_by_origin(
|
| 30 |
+
preds: list[Prediction],
|
| 31 |
+
seed_ids: set[str] | None = None,
|
| 32 |
+
) -> tuple[list[Prediction], list[Prediction]]:
|
| 33 |
+
"""Split predictions into (seed, live) lists."""
|
| 34 |
+
if seed_ids is None:
|
| 35 |
+
seed_ids = seed_prediction_ids()
|
| 36 |
+
seed: list[Prediction] = []
|
| 37 |
+
live: list[Prediction] = []
|
| 38 |
+
for p in preds:
|
| 39 |
+
if prediction_origin(p, seed_ids) == "seed":
|
| 40 |
+
seed.append(p)
|
| 41 |
+
else:
|
| 42 |
+
live.append(p)
|
| 43 |
+
return seed, live
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def scoreboard_subset(preds: list[Prediction]) -> dict:
|
| 47 |
+
"""Same shape as ``Registry.scoreboard()`` for an arbitrary prediction list."""
|
| 48 |
+
scored = [p for p in preds if p.brier is not None]
|
| 49 |
+
n = len(scored)
|
| 50 |
+
mean_brier = sum(p.brier for p in scored) / n if n else None
|
| 51 |
+
|
| 52 |
+
buckets: dict[int, list[Prediction]] = {}
|
| 53 |
+
for p in scored:
|
| 54 |
+
b = min(9, int(p.confidence * 10))
|
| 55 |
+
buckets.setdefault(b, []).append(p)
|
| 56 |
+
|
| 57 |
+
calibration = []
|
| 58 |
+
for b in sorted(buckets):
|
| 59 |
+
grp = buckets[b]
|
| 60 |
+
avg_conf = sum(x.confidence for x in grp) / len(grp)
|
| 61 |
+
hit_rate = sum(1 for x in grp if x.outcome) / len(grp)
|
| 62 |
+
calibration.append({
|
| 63 |
+
"bucket": f"{b*10}-{b*10+10}%",
|
| 64 |
+
"n": len(grp),
|
| 65 |
+
"avg_confidence": round(avg_conf, 3),
|
| 66 |
+
"actual_hit_rate": round(hit_rate, 3),
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
"total": len(preds),
|
| 71 |
+
"open": sum(1 for p in preds if p.status in _OPEN),
|
| 72 |
+
"resolved": n,
|
| 73 |
+
"ambiguous": sum(1 for p in preds if p.status == Status.ambiguous),
|
| 74 |
+
"mean_brier": round(mean_brier, 4) if mean_brier is not None else None,
|
| 75 |
+
"calibration": calibration,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def upcoming_resolutions(
|
| 80 |
+
preds: list[Prediction],
|
| 81 |
+
*,
|
| 82 |
+
today: date | None = None,
|
| 83 |
+
limit: int = 10,
|
| 84 |
+
) -> list[Prediction]:
|
| 85 |
+
"""Open/due predictions sorted by resolution_date (soonest first)."""
|
| 86 |
+
today = today or date.today()
|
| 87 |
+
open_preds = [p for p in preds if p.status in _OPEN]
|
| 88 |
+
open_preds.sort(key=lambda p: (p.resolution_date, p.created_at))
|
| 89 |
+
return open_preds[:limit]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def days_until_resolution(p: Prediction, today: date | None = None) -> int:
|
| 93 |
+
today = today or date.today()
|
| 94 |
+
return (p.resolution_date - today).days
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def verify_live_export_sync(
|
| 98 |
+
db_live: list[Prediction],
|
| 99 |
+
jsonl_live: list[Prediction],
|
| 100 |
+
) -> list[str]:
|
| 101 |
+
"""Return human-readable error strings; empty list means OK."""
|
| 102 |
+
by_id = {p.id: p for p in jsonl_live}
|
| 103 |
+
db_ids = {p.id for p in db_live}
|
| 104 |
+
errors: list[str] = []
|
| 105 |
+
|
| 106 |
+
for p in db_live:
|
| 107 |
+
j = by_id.get(p.id)
|
| 108 |
+
if j is None:
|
| 109 |
+
errors.append(f"missing in JSONL: [{p.id[:8]}] {p.statement[:50]}")
|
| 110 |
+
continue
|
| 111 |
+
if p.status != j.status:
|
| 112 |
+
errors.append(
|
| 113 |
+
f"status mismatch [{p.id[:8]}]: db={p.status.value} jsonl={j.status.value}"
|
| 114 |
+
)
|
| 115 |
+
if p.outcome != j.outcome:
|
| 116 |
+
errors.append(
|
| 117 |
+
f"outcome mismatch [{p.id[:8]}]: db={p.outcome} jsonl={j.outcome}"
|
| 118 |
+
)
|
| 119 |
+
if p.brier is not None and j.brier is not None:
|
| 120 |
+
if abs(p.brier - j.brier) > 1e-6:
|
| 121 |
+
errors.append(
|
| 122 |
+
f"brier mismatch [{p.id[:8]}]: db={p.brier} jsonl={j.brier}"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
for jid, j in by_id.items():
|
| 126 |
+
if jid not in db_ids:
|
| 127 |
+
errors.append(f"orphan in JSONL (not in DB live set): [{jid[:8]}]")
|
| 128 |
+
|
| 129 |
+
return errors
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def prediction_to_csv_row(p: Prediction, origin: Origin) -> dict:
|
| 133 |
+
return {
|
| 134 |
+
"origin": origin,
|
| 135 |
+
"id": p.fingerprint(),
|
| 136 |
+
"statement": p.statement,
|
| 137 |
+
"category": p.category,
|
| 138 |
+
"confidence": p.confidence,
|
| 139 |
+
"horizon": p.horizon,
|
| 140 |
+
"status": p.status.value,
|
| 141 |
+
"resolution_date": p.resolution_date.isoformat() if p.resolution_date else "",
|
| 142 |
+
"outcome": p.outcome,
|
| 143 |
+
"brier": p.brier,
|
| 144 |
+
"judged_rationale": p.judged_rationale,
|
| 145 |
+
"sources": " | ".join(p.sources or []),
|
| 146 |
+
"resolved_at": (
|
| 147 |
+
p.resolved_at.isoformat() if p.resolved_at else ""
|
| 148 |
+
),
|
| 149 |
+
}
|
tests/test_track_record.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for services/track_record.py (HR-11)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from datetime import date
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 12 |
+
|
| 13 |
+
from sqlmodel import Session, delete
|
| 14 |
+
|
| 15 |
+
from registry import Registry
|
| 16 |
+
from schemas import Prediction, Status, engine
|
| 17 |
+
from services.track_record import (
|
| 18 |
+
partition_by_origin,
|
| 19 |
+
prediction_origin,
|
| 20 |
+
scoreboard_subset,
|
| 21 |
+
seed_prediction_ids,
|
| 22 |
+
upcoming_resolutions,
|
| 23 |
+
verify_live_export_sync,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _clean():
|
| 28 |
+
with Session(engine) as session:
|
| 29 |
+
session.exec(delete(Prediction))
|
| 30 |
+
session.commit()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_seed_prediction_ids_loads_from_file():
|
| 34 |
+
ids = seed_prediction_ids()
|
| 35 |
+
assert len(ids) >= 8
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_partition_by_origin(tmp_path):
|
| 39 |
+
seed = tmp_path / "seed.json"
|
| 40 |
+
seed.write_text(json.dumps([{
|
| 41 |
+
"statement": "Seed only claim",
|
| 42 |
+
"rationale": "r", "category": "macro", "confidence": 0.6,
|
| 43 |
+
"horizon": "2026-Q4", "resolution_date": "2026-12-31",
|
| 44 |
+
"resolution_criteria": "c", "status": "resolved_true",
|
| 45 |
+
"outcome": True, "judged_rationale": "ok",
|
| 46 |
+
}]), encoding="utf-8")
|
| 47 |
+
seed_ids = seed_prediction_ids(seed)
|
| 48 |
+
assert len(seed_ids) == 1
|
| 49 |
+
|
| 50 |
+
seed_p = Prediction.model_validate(json.loads(seed.read_text())[0]).assign_id()
|
| 51 |
+
live_p = Prediction(
|
| 52 |
+
statement="Live cron claim",
|
| 53 |
+
rationale="r", confidence=0.5, horizon="2027-Q1",
|
| 54 |
+
resolution_date=date(2027, 3, 31), resolution_criteria="c",
|
| 55 |
+
).assign_id()
|
| 56 |
+
|
| 57 |
+
s, l = partition_by_origin([seed_p, live_p], seed_ids)
|
| 58 |
+
assert len(s) == 1 and len(l) == 1
|
| 59 |
+
assert prediction_origin(seed_p, seed_ids) == "seed"
|
| 60 |
+
assert prediction_origin(live_p, seed_ids) == "live"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_scoreboard_subset_mean_brier():
|
| 64 |
+
p_hit = Prediction(
|
| 65 |
+
statement="Hit", rationale="r", confidence=0.8, horizon="2026-Q1",
|
| 66 |
+
resolution_date=date(2026, 3, 31), resolution_criteria="c",
|
| 67 |
+
)
|
| 68 |
+
p_hit.resolve(True, "yes")
|
| 69 |
+
p_miss = Prediction(
|
| 70 |
+
statement="Miss", rationale="r", confidence=0.8, horizon="2026-Q1",
|
| 71 |
+
resolution_date=date(2026, 3, 31), resolution_criteria="c",
|
| 72 |
+
)
|
| 73 |
+
p_miss.resolve(False, "no")
|
| 74 |
+
sb = scoreboard_subset([p_hit, p_miss])
|
| 75 |
+
assert sb["resolved"] == 2
|
| 76 |
+
assert sb["mean_brier"] == pytest.approx(0.34)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_upcoming_resolutions_sorted():
|
| 80 |
+
p1 = Prediction(
|
| 81 |
+
statement="Soon", rationale="r", confidence=0.5, horizon="2026-Q2",
|
| 82 |
+
resolution_date=date(2026, 6, 30), resolution_criteria="c",
|
| 83 |
+
).assign_id()
|
| 84 |
+
p2 = Prediction(
|
| 85 |
+
statement="Later", rationale="r", confidence=0.5, horizon="2027-Q1",
|
| 86 |
+
resolution_date=date(2027, 3, 31), resolution_criteria="c",
|
| 87 |
+
).assign_id()
|
| 88 |
+
out = upcoming_resolutions([p2, p1], today=date(2026, 1, 1))
|
| 89 |
+
assert out[0].statement == "Soon"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_verify_live_export_sync_ok():
|
| 93 |
+
p = Prediction(
|
| 94 |
+
statement="Sync test", rationale="r", confidence=0.5, horizon="2026-Q4",
|
| 95 |
+
resolution_date=date(2026, 12, 31), resolution_criteria="c",
|
| 96 |
+
).assign_id()
|
| 97 |
+
assert verify_live_export_sync([p], [p]) == []
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_verify_live_export_detects_status_mismatch():
|
| 101 |
+
p_db = Prediction(
|
| 102 |
+
statement="Resolved live", rationale="r", confidence=0.7, horizon="2026-Q1",
|
| 103 |
+
resolution_date=date(2026, 3, 31), resolution_criteria="c",
|
| 104 |
+
).assign_id()
|
| 105 |
+
p_db.resolve(True, "facts")
|
| 106 |
+
p_jsonl = Prediction(
|
| 107 |
+
statement="Resolved live", rationale="r", confidence=0.7, horizon="2026-Q1",
|
| 108 |
+
resolution_date=date(2026, 3, 31), resolution_criteria="c",
|
| 109 |
+
status=Status.open,
|
| 110 |
+
).assign_id()
|
| 111 |
+
p_jsonl.id = p_db.id
|
| 112 |
+
errs = verify_live_export_sync([p_db], [p_jsonl])
|
| 113 |
+
assert any("status mismatch" in e for e in errs)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_export_live_only_excludes_seed(tmp_path):
|
| 117 |
+
import run
|
| 118 |
+
|
| 119 |
+
_clean()
|
| 120 |
+
seed = tmp_path / "seed.json"
|
| 121 |
+
seed.write_text(json.dumps([{
|
| 122 |
+
"statement": "Curated seed row",
|
| 123 |
+
"rationale": "r", "category": "macro", "confidence": 0.6,
|
| 124 |
+
"horizon": "2026-Q4", "resolution_date": "2026-12-31",
|
| 125 |
+
"resolution_criteria": "c", "status": "open",
|
| 126 |
+
}]), encoding="utf-8")
|
| 127 |
+
seed_p = Prediction.model_validate(json.loads(seed.read_text())[0]).assign_id()
|
| 128 |
+
live_p = Prediction(
|
| 129 |
+
statement="Daily LLM row",
|
| 130 |
+
rationale="r", confidence=0.5, horizon="2027-Q2",
|
| 131 |
+
resolution_date=date(2027, 6, 30), resolution_criteria="c",
|
| 132 |
+
).assign_id()
|
| 133 |
+
Registry().add_many([seed_p, live_p])
|
| 134 |
+
|
| 135 |
+
out = tmp_path / "live.jsonl"
|
| 136 |
+
run.cmd_export(
|
| 137 |
+
{"database_path": "data/forecaster.db"},
|
| 138 |
+
str(out),
|
| 139 |
+
seed_path=str(seed),
|
| 140 |
+
)
|
| 141 |
+
lines = [ln for ln in out.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
| 142 |
+
assert len(lines) == 1
|
| 143 |
+
assert "Daily LLM row" in lines[0]
|
| 144 |
+
assert "Curated seed row" not in lines[0]
|
| 145 |
+
_clean()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_verify_export_cli(tmp_path):
|
| 149 |
+
import run
|
| 150 |
+
|
| 151 |
+
_clean()
|
| 152 |
+
seed = tmp_path / "seed.json"
|
| 153 |
+
seed.write_text("[]", encoding="utf-8")
|
| 154 |
+
p = Prediction(
|
| 155 |
+
statement="CLI verify", rationale="r", confidence=0.5, horizon="2026-Q4",
|
| 156 |
+
resolution_date=date(2026, 12, 31), resolution_criteria="c",
|
| 157 |
+
).assign_id()
|
| 158 |
+
Registry().add_many([p])
|
| 159 |
+
out = tmp_path / "live.jsonl"
|
| 160 |
+
run.cmd_export(
|
| 161 |
+
{"database_path": "data/forecaster.db"},
|
| 162 |
+
str(out),
|
| 163 |
+
seed_path=str(seed),
|
| 164 |
+
)
|
| 165 |
+
run.cmd_verify_export(
|
| 166 |
+
{"database_path": "data/forecaster.db"},
|
| 167 |
+
str(out),
|
| 168 |
+
seed_path=str(seed),
|
| 169 |
+
)
|
| 170 |
+
_clean()
|
ui/i18n.py
CHANGED
|
@@ -220,9 +220,33 @@ Compare your AI scenario with **15+ historical tech transitions**.
|
|
| 220 |
# ── accuracy tab ─────────────────────────────────────────────────────────
|
| 221 |
"acc_title": {"en": "Forecast accuracy tracker", "zh": "预测准确度追踪"},
|
| 222 |
"acc_intro": {
|
| 223 |
-
"en": "Tracks AI forecasts vs real outcomes.
|
| 224 |
-
"zh": "追踪 AI 预测与现实结果。**
|
| 225 |
-
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
"acc_total": {"en": "Total predictions", "zh": "预测总数"},
|
| 227 |
"acc_open": {"en": "Active / open", "zh": "进行中"},
|
| 228 |
"acc_resolved": {"en": "Resolved", "zh": "已解析"},
|
|
|
|
| 220 |
# ── accuracy tab ─────────────────────────────────────────────────────────
|
| 221 |
"acc_title": {"en": "Forecast accuracy tracker", "zh": "预测准确度追踪"},
|
| 222 |
"acc_intro": {
|
| 223 |
+
"en": "Tracks AI forecasts vs real outcomes. **Curated benchmark** rows are verifiable historical demos; **Live LLM** rows come from the daily forecasting cron.",
|
| 224 |
+
"zh": "追踪 AI 预测与现实结果。**精选基准** 为可核查的历史演示;**Live LLM** 来自每日预测 cron。",
|
| 225 |
+
},
|
| 226 |
+
"acc_origin_note": {
|
| 227 |
+
"en": "Two track records are shown separately so you can judge real agent performance without confusing it with curated demos.",
|
| 228 |
+
"zh": "两类战绩分开展示,避免将真实 LLM 表现与精选演示数据混淆。",
|
| 229 |
+
},
|
| 230 |
+
"acc_seed_panel": {"en": "📚 Curated benchmark (seed)", "zh": "📚 精选基准(seed)"},
|
| 231 |
+
"acc_live_panel": {"en": "🤖 Live LLM predictions", "zh": "🤖 Live LLM 预测"},
|
| 232 |
+
"acc_seed_explain": {
|
| 233 |
+
"en": "Hand-picked, source-backed predictions with known outcomes — used to demonstrate calibration honesty (includes deliberate misses).",
|
| 234 |
+
"zh": "人工精选、有来源背书、结果已知的预测——用于展示诚实的校准能力(含故意 MISS)。",
|
| 235 |
+
},
|
| 236 |
+
"acc_live_explain": {
|
| 237 |
+
"en": "Generated daily by Groq LLM; outcomes resolve automatically when `resolution_date` passes. This is the agent's real public scoreboard.",
|
| 238 |
+
"zh": "由 Groq LLM 每日生成;到达 `resolution_date` 后自动判定。这是代理的真实公开战绩。",
|
| 239 |
+
},
|
| 240 |
+
"acc_upcoming_title": {"en": "Upcoming live resolutions", "zh": "即将判定的 Live 预测"},
|
| 241 |
+
"acc_upcoming_intro": {
|
| 242 |
+
"en": "These live predictions will be judged automatically on their resolution date — watch this list shrink as the loop closes.",
|
| 243 |
+
"zh": "以下 Live 预测将在解析日自动判定——随着闭环运转,此列表会逐渐缩短。",
|
| 244 |
+
},
|
| 245 |
+
"acc_no_upcoming": {"en": "No open live predictions scheduled.", "zh": "暂无待判定的 Live 预测。"},
|
| 246 |
+
"col_origin": {"en": "Origin", "zh": "来源"},
|
| 247 |
+
"col_days_left": {"en": "Days left", "zh": "剩余天数"},
|
| 248 |
+
"origin_seed": {"en": "Benchmark", "zh": "基准"},
|
| 249 |
+
"origin_live": {"en": "Live LLM", "zh": "Live LLM"},
|
| 250 |
"acc_total": {"en": "Total predictions", "zh": "预测总数"},
|
| 251 |
"acc_open": {"en": "Active / open", "zh": "进行中"},
|
| 252 |
"acc_resolved": {"en": "Resolved", "zh": "已解析"},
|
ui/tabs/accuracy.py
CHANGED
|
@@ -1,76 +1,158 @@
|
|
| 1 |
-
"""Tab: accuracy."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
-
import
|
| 5 |
-
import plotly.graph_objects as go
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
|
|
|
|
|
|
|
| 8 |
import streamlit as st
|
| 9 |
-
|
| 10 |
import market
|
| 11 |
-
from schemas import Status
|
|
|
|
|
|
|
| 12 |
from ui.i18n import filter_all_label, prediction_category_label, t
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def render(scenario_input: dict, prior, job_radar_cfg: dict):
|
| 16 |
st.subheader(t("acc_title"))
|
| 17 |
st.markdown(t("acc_intro"))
|
|
|
|
|
|
|
| 18 |
try:
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
except Exception as e:
|
| 22 |
st.error(t("acc_registry_err", e=e))
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
unsafe_allow_html=True,
|
| 38 |
-
)
|
| 39 |
-
with col3:
|
| 40 |
-
st.markdown(
|
| 41 |
-
f'<div class="metric-card"><div class="metric-value">{sb["resolved"]}</div>'
|
| 42 |
-
f'<div class="metric-label">{t("acc_resolved")}</div></div>',
|
| 43 |
-
unsafe_allow_html=True,
|
| 44 |
-
)
|
| 45 |
-
with col4:
|
| 46 |
-
brier_str = f"{sb['mean_brier']:.4f}" if sb['mean_brier'] is not None else "N/A"
|
| 47 |
-
st.markdown(
|
| 48 |
-
f'<div class="metric-card"><div class="metric-value">{brier_str}</div>'
|
| 49 |
-
f'<div class="metric-label">{t("acc_brier")}</div></div>',
|
| 50 |
-
unsafe_allow_html=True,
|
| 51 |
-
)
|
| 52 |
|
| 53 |
st.caption(t("acc_brier_note"))
|
| 54 |
with st.expander(t("acc_brier_explain_title"), expanded=False):
|
| 55 |
st.markdown(t("acc_brier_explain_body"))
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if calibration:
|
| 59 |
df_cal = pd.DataFrame(calibration)
|
| 60 |
fig = go.Figure()
|
| 61 |
fig.add_trace(go.Scatter(
|
| 62 |
x=[0.5, 1.0], y=[0.5, 1.0],
|
| 63 |
-
mode="lines",
|
| 64 |
-
|
| 65 |
-
line=dict(color="#8b949e", dash="dash"),
|
| 66 |
-
hoverinfo="none",
|
| 67 |
))
|
| 68 |
fig.add_trace(go.Scatter(
|
| 69 |
x=df_cal["avg_confidence"], y=df_cal["actual_hit_rate"],
|
| 70 |
-
mode="markers+lines",
|
| 71 |
-
name="Agent",
|
| 72 |
line=dict(color="#58a6ff", width=3),
|
| 73 |
-
marker=dict(size=10, color="#bc8cff", symbol="circle",
|
|
|
|
| 74 |
text=[f"N={row['n']}" for _, row in df_cal.iterrows()],
|
| 75 |
hoverinfo="text+x+y",
|
| 76 |
))
|
|
@@ -90,80 +172,71 @@ def render(scenario_input: dict, prior, job_radar_cfg: dict):
|
|
| 90 |
else:
|
| 91 |
st.info(t("acc_no_cal"))
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
st.markdown("---")
|
| 94 |
st.subheader(t("acc_explore"))
|
| 95 |
|
| 96 |
all_label = filter_all_label()
|
| 97 |
-
if
|
| 98 |
-
cat_options = [all_label] + sorted(list(set(p.category for p in
|
| 99 |
selected_cat = st.selectbox(
|
| 100 |
t("acc_filter_cat"),
|
| 101 |
cat_options,
|
| 102 |
format_func=lambda c: all_label if c == all_label else prediction_category_label(c),
|
| 103 |
)
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
|
| 112 |
-
|
| 113 |
-
p for p in
|
| 114 |
-
if p.status in (Status.resolved_true, Status.resolved_false)
|
| 115 |
-
]
|
| 116 |
-
resolved_preds.sort(
|
| 117 |
key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
|
| 118 |
reverse=True,
|
| 119 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
st.dataframe(active_df, width="stretch", hide_index=True)
|
| 134 |
-
else:
|
| 135 |
-
st.write(t("acc_no_active"))
|
| 136 |
-
|
| 137 |
-
with col_resolved:
|
| 138 |
-
st.markdown(f"### {t('acc_resolved_list', n=len(resolved_preds))}")
|
| 139 |
-
if resolved_preds:
|
| 140 |
-
col_src_key = t("col_sources")
|
| 141 |
-
resolved_rows = []
|
| 142 |
-
for p in resolved_preds:
|
| 143 |
-
first_src = (p.sources or [])[0] if p.sources else ""
|
| 144 |
-
resolved_rows.append({
|
| 145 |
-
t("col_id"): p.fingerprint(),
|
| 146 |
-
t("col_statement"): p.statement,
|
| 147 |
-
t("col_outcome"): t("outcome_true") if p.outcome else t("outcome_false"),
|
| 148 |
-
t("col_confidence"): f"{p.confidence*100:.0f}%",
|
| 149 |
-
t("col_brier"): f"{p.brier:.4f}" if p.brier is not None else "",
|
| 150 |
-
t("col_rationale"): p.judged_rationale,
|
| 151 |
-
col_src_key: first_src,
|
| 152 |
-
})
|
| 153 |
-
resolved_df = pd.DataFrame(resolved_rows)
|
| 154 |
-
st.dataframe(
|
| 155 |
-
resolved_df,
|
| 156 |
-
column_config={
|
| 157 |
-
col_src_key: st.column_config.LinkColumn(
|
| 158 |
-
t("col_sources"),
|
| 159 |
-
display_text=t("col_sources_label"),
|
| 160 |
-
),
|
| 161 |
-
},
|
| 162 |
-
use_container_width=True,
|
| 163 |
-
hide_index=True,
|
| 164 |
-
)
|
| 165 |
-
else:
|
| 166 |
-
st.write(t("acc_no_resolved"))
|
| 167 |
else:
|
| 168 |
st.write(t("acc_no_preds"))
|
| 169 |
|
|
@@ -171,35 +244,27 @@ def render(scenario_input: dict, prior, job_radar_cfg: dict):
|
|
| 171 |
st.markdown("---")
|
| 172 |
st.subheader(t("acc_download_title"))
|
| 173 |
st.markdown(t("acc_download_intro"))
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
st.download_button(
|
| 196 |
-
label=t("acc_download_btn"),
|
| 197 |
-
data=buf.getvalue().encode("utf-8"),
|
| 198 |
-
file_name="forecaster_track_record.csv",
|
| 199 |
-
mime="text/csv",
|
| 200 |
-
)
|
| 201 |
-
except Exception:
|
| 202 |
-
pass
|
| 203 |
|
| 204 |
st.markdown("---")
|
| 205 |
st.subheader(t("acc_market_title"))
|
|
|
|
| 1 |
+
"""Tab: accuracy — track record with seed vs live origin split (HR-11)."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import io
|
|
|
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
import streamlit as st
|
| 10 |
+
|
| 11 |
import market
|
| 12 |
+
from schemas import Prediction, Status
|
| 13 |
+
from services.dashboard_data import get_predictions, get_track_record_views
|
| 14 |
+
from services.track_record import days_until_resolution, prediction_origin, prediction_to_csv_row
|
| 15 |
from ui.i18n import filter_all_label, prediction_category_label, t
|
| 16 |
|
| 17 |
|
| 18 |
+
def _brier_str(val) -> str:
|
| 19 |
+
return f"{val:.4f}" if val is not None else "N/A"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _scoreboard_cards(sb: dict, *, prefix: str) -> None:
|
| 23 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 24 |
+
with c1:
|
| 25 |
+
st.metric(t("acc_total"), sb["total"], key=f"{prefix}_total")
|
| 26 |
+
with c2:
|
| 27 |
+
st.metric(t("acc_open"), sb["open"], key=f"{prefix}_open")
|
| 28 |
+
with c3:
|
| 29 |
+
st.metric(t("acc_resolved"), sb["resolved"], key=f"{prefix}_resolved")
|
| 30 |
+
with c4:
|
| 31 |
+
st.metric(t("acc_brier"), _brier_str(sb["mean_brier"]), key=f"{prefix}_brier")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _resolved_table(preds: list[Prediction], origin_label: str) -> None:
|
| 35 |
+
if not preds:
|
| 36 |
+
st.write(t("acc_no_resolved"))
|
| 37 |
+
return
|
| 38 |
+
col_src_key = t("col_sources")
|
| 39 |
+
rows = []
|
| 40 |
+
for p in preds:
|
| 41 |
+
first_src = (p.sources or [])[0] if p.sources else ""
|
| 42 |
+
rows.append({
|
| 43 |
+
t("col_origin"): origin_label,
|
| 44 |
+
t("col_id"): p.fingerprint(),
|
| 45 |
+
t("col_statement"): p.statement,
|
| 46 |
+
t("col_outcome"): t("outcome_true") if p.outcome else t("outcome_false"),
|
| 47 |
+
t("col_confidence"): f"{p.confidence*100:.0f}%",
|
| 48 |
+
t("col_brier"): f"{p.brier:.4f}" if p.brier is not None else "",
|
| 49 |
+
t("col_rationale"): p.judged_rationale,
|
| 50 |
+
col_src_key: first_src,
|
| 51 |
+
})
|
| 52 |
+
st.dataframe(
|
| 53 |
+
pd.DataFrame(rows),
|
| 54 |
+
column_config={
|
| 55 |
+
col_src_key: st.column_config.LinkColumn(
|
| 56 |
+
t("col_sources"),
|
| 57 |
+
display_text=t("col_sources_label"),
|
| 58 |
+
),
|
| 59 |
+
},
|
| 60 |
+
use_container_width=True,
|
| 61 |
+
hide_index=True,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _active_table(preds: list[Prediction], origin_label: str) -> None:
|
| 66 |
+
if not preds:
|
| 67 |
+
st.write(t("acc_no_active"))
|
| 68 |
+
return
|
| 69 |
+
st.dataframe(
|
| 70 |
+
pd.DataFrame([{
|
| 71 |
+
t("col_origin"): origin_label,
|
| 72 |
+
t("col_id"): p.fingerprint(),
|
| 73 |
+
t("col_statement"): p.statement,
|
| 74 |
+
t("col_category"): prediction_category_label(p.category),
|
| 75 |
+
t("col_confidence"): f"{p.confidence*100:.0f}%",
|
| 76 |
+
t("col_horizon"): p.horizon,
|
| 77 |
+
t("col_resolution"): p.resolution_date.strftime("%Y-%m-%d"),
|
| 78 |
+
} for p in preds]),
|
| 79 |
+
use_container_width=True,
|
| 80 |
+
hide_index=True,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def render(scenario_input: dict, prior, job_radar_cfg: dict):
|
| 85 |
st.subheader(t("acc_title"))
|
| 86 |
st.markdown(t("acc_intro"))
|
| 87 |
+
st.caption(t("acc_origin_note"))
|
| 88 |
+
|
| 89 |
try:
|
| 90 |
+
views = get_track_record_views()
|
| 91 |
+
seed_preds = views["seed_preds"]
|
| 92 |
+
live_preds = views["live_preds"]
|
| 93 |
+
seed_sb = views["seed_scoreboard"]
|
| 94 |
+
live_sb = views["live_scoreboard"]
|
| 95 |
+
upcoming = views["upcoming_live"]
|
| 96 |
+
seed_ids = views["seed_ids"]
|
| 97 |
+
all_preds = seed_preds + live_preds
|
| 98 |
+
sb_combined = {
|
| 99 |
+
"total": seed_sb["total"] + live_sb["total"],
|
| 100 |
+
"open": seed_sb["open"] + live_sb["open"],
|
| 101 |
+
"resolved": seed_sb["resolved"] + live_sb["resolved"],
|
| 102 |
+
"mean_brier": None,
|
| 103 |
+
}
|
| 104 |
+
all_briers = [
|
| 105 |
+
p.brier for p in all_preds
|
| 106 |
+
if p.brier is not None
|
| 107 |
+
]
|
| 108 |
+
if all_briers:
|
| 109 |
+
sb_combined["mean_brier"] = round(sum(all_briers) / len(all_briers), 4)
|
| 110 |
except Exception as e:
|
| 111 |
st.error(t("acc_registry_err", e=e))
|
| 112 |
+
seed_preds, live_preds, upcoming = [], [], []
|
| 113 |
+
seed_sb = live_sb = sb_combined = {
|
| 114 |
+
"total": 0, "open": 0, "resolved": 0, "mean_brier": None, "calibration": [],
|
| 115 |
+
}
|
| 116 |
+
seed_ids = set()
|
| 117 |
+
all_preds = []
|
| 118 |
+
|
| 119 |
+
st.markdown(f"### {t('acc_seed_panel')}")
|
| 120 |
+
st.caption(t("acc_seed_explain"))
|
| 121 |
+
_scoreboard_cards(seed_sb, prefix="seed")
|
| 122 |
+
|
| 123 |
+
st.markdown(f"### {t('acc_live_panel')}")
|
| 124 |
+
st.caption(t("acc_live_explain"))
|
| 125 |
+
_scoreboard_cards(live_sb, prefix="live")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
st.caption(t("acc_brier_note"))
|
| 128 |
with st.expander(t("acc_brier_explain_title"), expanded=False):
|
| 129 |
st.markdown(t("acc_brier_explain_body"))
|
| 130 |
|
| 131 |
+
# Calibration curve uses all resolved predictions
|
| 132 |
+
resolved_all = [
|
| 133 |
+
p for p in all_preds
|
| 134 |
+
if p.status in (Status.resolved_true, Status.resolved_false)
|
| 135 |
+
]
|
| 136 |
+
if resolved_all:
|
| 137 |
+
from services.track_record import scoreboard_subset
|
| 138 |
+
calibration = scoreboard_subset(resolved_all).get("calibration", [])
|
| 139 |
+
else:
|
| 140 |
+
calibration = []
|
| 141 |
+
|
| 142 |
if calibration:
|
| 143 |
df_cal = pd.DataFrame(calibration)
|
| 144 |
fig = go.Figure()
|
| 145 |
fig.add_trace(go.Scatter(
|
| 146 |
x=[0.5, 1.0], y=[0.5, 1.0],
|
| 147 |
+
mode="lines", name="Perfect",
|
| 148 |
+
line=dict(color="#8b949e", dash="dash"), hoverinfo="none",
|
|
|
|
|
|
|
| 149 |
))
|
| 150 |
fig.add_trace(go.Scatter(
|
| 151 |
x=df_cal["avg_confidence"], y=df_cal["actual_hit_rate"],
|
| 152 |
+
mode="markers+lines", name="Agent",
|
|
|
|
| 153 |
line=dict(color="#58a6ff", width=3),
|
| 154 |
+
marker=dict(size=10, color="#bc8cff", symbol="circle",
|
| 155 |
+
line=dict(color="#58a6ff", width=2)),
|
| 156 |
text=[f"N={row['n']}" for _, row in df_cal.iterrows()],
|
| 157 |
hoverinfo="text+x+y",
|
| 158 |
))
|
|
|
|
| 172 |
else:
|
| 173 |
st.info(t("acc_no_cal"))
|
| 174 |
|
| 175 |
+
st.markdown("---")
|
| 176 |
+
st.markdown(f"### {t('acc_upcoming_title')}")
|
| 177 |
+
st.caption(t("acc_upcoming_intro"))
|
| 178 |
+
if upcoming:
|
| 179 |
+
st.dataframe(
|
| 180 |
+
pd.DataFrame([{
|
| 181 |
+
t("col_statement"): p.statement,
|
| 182 |
+
t("col_confidence"): f"{p.confidence*100:.0f}%",
|
| 183 |
+
t("col_resolution"): p.resolution_date.strftime("%Y-%m-%d"),
|
| 184 |
+
t("col_days_left"): days_until_resolution(p),
|
| 185 |
+
t("col_rationale"): p.resolution_criteria[:120],
|
| 186 |
+
} for p in upcoming]),
|
| 187 |
+
use_container_width=True,
|
| 188 |
+
hide_index=True,
|
| 189 |
+
)
|
| 190 |
+
else:
|
| 191 |
+
st.info(t("acc_no_upcoming"))
|
| 192 |
+
|
| 193 |
st.markdown("---")
|
| 194 |
st.subheader(t("acc_explore"))
|
| 195 |
|
| 196 |
all_label = filter_all_label()
|
| 197 |
+
if all_preds:
|
| 198 |
+
cat_options = [all_label] + sorted(list(set(p.category for p in all_preds)))
|
| 199 |
selected_cat = st.selectbox(
|
| 200 |
t("acc_filter_cat"),
|
| 201 |
cat_options,
|
| 202 |
format_func=lambda c: all_label if c == all_label else prediction_category_label(c),
|
| 203 |
)
|
| 204 |
|
| 205 |
+
def _filter_group(group: list[Prediction]) -> list[Prediction]:
|
| 206 |
+
if selected_cat == all_label:
|
| 207 |
+
return group
|
| 208 |
+
return [p for p in group if p.category == selected_cat]
|
| 209 |
|
| 210 |
+
seed_f = _filter_group(seed_preds)
|
| 211 |
+
live_f = _filter_group(live_preds)
|
| 212 |
|
| 213 |
+
seed_resolved = sorted(
|
| 214 |
+
[p for p in seed_f if p.status in (Status.resolved_true, Status.resolved_false)],
|
|
|
|
|
|
|
|
|
|
| 215 |
key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
|
| 216 |
reverse=True,
|
| 217 |
)
|
| 218 |
+
live_resolved = sorted(
|
| 219 |
+
[p for p in live_f if p.status in (Status.resolved_true, Status.resolved_false)],
|
| 220 |
+
key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
|
| 221 |
+
reverse=True,
|
| 222 |
+
)
|
| 223 |
+
live_open = sorted(
|
| 224 |
+
[p for p in live_f if p.status in (Status.open, Status.due)],
|
| 225 |
+
key=lambda p: p.resolution_date,
|
| 226 |
+
)
|
| 227 |
|
| 228 |
+
st.markdown(f"#### {t('acc_seed_panel')}")
|
| 229 |
+
st.markdown(f"**{t('acc_resolved_list', n=len(seed_resolved))}**")
|
| 230 |
+
_resolved_table(seed_resolved, t("origin_seed"))
|
| 231 |
+
|
| 232 |
+
st.markdown(f"#### {t('acc_live_panel')}")
|
| 233 |
+
col_live_open, col_live_res = st.columns(2)
|
| 234 |
+
with col_live_open:
|
| 235 |
+
st.markdown(f"**{t('acc_active', n=len(live_open))}**")
|
| 236 |
+
_active_table(live_open, t("origin_live"))
|
| 237 |
+
with col_live_res:
|
| 238 |
+
st.markdown(f"**{t('acc_resolved_list', n=len(live_resolved))}**")
|
| 239 |
+
_resolved_table(live_resolved, t("origin_live"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
else:
|
| 241 |
st.write(t("acc_no_preds"))
|
| 242 |
|
|
|
|
| 244 |
st.markdown("---")
|
| 245 |
st.subheader(t("acc_download_title"))
|
| 246 |
st.markdown(t("acc_download_intro"))
|
| 247 |
+
resolved_all_sorted = sorted(
|
| 248 |
+
[p for p in all_preds if p.status in (Status.resolved_true, Status.resolved_false)],
|
| 249 |
+
key=lambda p: p.resolved_at or datetime.min.replace(tzinfo=timezone.utc),
|
| 250 |
+
reverse=True,
|
| 251 |
+
)
|
| 252 |
+
if resolved_all_sorted:
|
| 253 |
+
csv_rows = [
|
| 254 |
+
prediction_to_csv_row(
|
| 255 |
+
p,
|
| 256 |
+
"seed" if prediction_origin(p, seed_ids) == "seed" else "live",
|
| 257 |
+
)
|
| 258 |
+
for p in resolved_all_sorted
|
| 259 |
+
]
|
| 260 |
+
buf = io.StringIO()
|
| 261 |
+
pd.DataFrame(csv_rows).to_csv(buf, index=False)
|
| 262 |
+
st.download_button(
|
| 263 |
+
label=t("acc_download_btn"),
|
| 264 |
+
data=buf.getvalue().encode("utf-8"),
|
| 265 |
+
file_name="forecaster_track_record.csv",
|
| 266 |
+
mime="text/csv",
|
| 267 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
st.markdown("---")
|
| 270 |
st.subheader(t("acc_market_title"))
|