feat: add GET /api/stats and GET /api/stats/download endpoints
This commit is contained in:
parent
c5a74d3821
commit
f64be8bbe0
2 changed files with 72 additions and 0 deletions
28
app/api.py
28
app/api.py
|
|
@ -245,6 +245,34 @@ def post_config(payload: ConfigPayload):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
def get_stats():
|
||||||
|
records = _read_jsonl(_score_file())
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for r in records:
|
||||||
|
lbl = r.get("label", "")
|
||||||
|
if lbl:
|
||||||
|
counts[lbl] = counts.get(lbl, 0) + 1
|
||||||
|
return {
|
||||||
|
"total": len(records),
|
||||||
|
"counts": counts,
|
||||||
|
"score_file_bytes": _score_file().stat().st_size if _score_file().exists() else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats/download")
|
||||||
|
def download_stats():
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
if not _score_file().exists():
|
||||||
|
raise HTTPException(404, "No score file")
|
||||||
|
return FileResponse(
|
||||||
|
str(_score_file()),
|
||||||
|
filename="email_score.jsonl",
|
||||||
|
media_type="application/jsonlines",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="email_score.jsonl"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Static SPA — MUST be last (catches all unmatched paths)
|
# Static SPA — MUST be last (catches all unmatched paths)
|
||||||
_DIST = _ROOT / "web" / "dist"
|
_DIST = _ROOT / "web" / "dist"
|
||||||
if _DIST.exists():
|
if _DIST.exists():
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,47 @@ def test_get_config_round_trips(client, config_dir):
|
||||||
data = r.json()
|
data = r.json()
|
||||||
assert data["max_per_account"] == 300
|
assert data["max_per_account"] == 300
|
||||||
assert data["accounts"][0]["name"] == "R"
|
assert data["accounts"][0]["name"] == "R"
|
||||||
|
|
||||||
|
|
||||||
|
# ── /api/stats ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def score_with_labels(tmp_path, data_dir):
|
||||||
|
"""Write a score file with 3 labels for stats tests."""
|
||||||
|
score_path = data_dir / "email_score.jsonl"
|
||||||
|
records = [
|
||||||
|
{"id": "a", "label": "interview_scheduled"},
|
||||||
|
{"id": "b", "label": "interview_scheduled"},
|
||||||
|
{"id": "c", "label": "rejected"},
|
||||||
|
]
|
||||||
|
score_path.write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_returns_counts(client, score_with_labels):
|
||||||
|
r = client.get("/api/stats")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 3
|
||||||
|
assert data["counts"]["interview_scheduled"] == 2
|
||||||
|
assert data["counts"]["rejected"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_empty_when_no_file(client, data_dir):
|
||||||
|
r = client.get("/api/stats")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["counts"] == {}
|
||||||
|
assert data["score_file_bytes"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_download_returns_file(client, score_with_labels):
|
||||||
|
r = client.get("/api/stats/download")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "jsonlines" in r.headers.get("content-type", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_download_404_when_no_file(client, data_dir):
|
||||||
|
r = client.get("/api/stats/download")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue