feat: async score jobs — POST /api/score/async + 评分记录 page
Each async score job:
- Runs InlineScorer.score() in thread pool
- Writes standard run artifacts (metadata.json, scores.csv, summary.md)
- Runs optimization_advisor => optimization_advice.md
- Result appears in 运行列表 and 报告详情 with full report
New endpoints:
- POST /api/score/async (202, job_id immediate)
- GET /api/score/jobs (list all jobs)
- GET /api/score/jobs/{id} (single job status)
Frontend:
- 评分记录 nav page with card list
- 5s auto-polling for queued/running jobs
- 查看报告 button navigates to existing 报告详情 page
Dify: change /api/score -> /api/score/async, no response parsing needed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
146
tests/webapp/test_score_jobs_api.py
Normal file
146
tests/webapp/test_score_jobs_api.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Tests for async score jobs API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""TestClient with fresh ScoreJobManager backed by tmp dirs."""
|
||||
import webapp.services.score_job_manager as mgr_mod
|
||||
from webapp.services.score_job_manager import ScoreJobManager
|
||||
|
||||
fresh_mgr = ScoreJobManager(
|
||||
output_dir=tmp_path / "score-async",
|
||||
index_dir=tmp_path / "score-jobs",
|
||||
max_workers=2,
|
||||
)
|
||||
monkeypatch.setattr(mgr_mod, "score_job_manager", fresh_mgr)
|
||||
|
||||
import webapp.api.score_jobs as api_mod
|
||||
monkeypatch.setattr(api_mod, "score_job_manager", fresh_mgr)
|
||||
|
||||
from webapp.server import create_app
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
class TestAsyncScoreEndpoints:
|
||||
def test_submit_returns_202_with_job_id(self, client):
|
||||
"""POST /api/score/async returns 202 immediately."""
|
||||
with patch("webapp.services.score_job_manager.ScoreJobManager._run"):
|
||||
resp = client.post("/api/score/async", json={
|
||||
"question": "q?",
|
||||
"answer": "a.",
|
||||
"metrics": ["answer_relevancy"],
|
||||
})
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert "job_id" in data
|
||||
assert data["status"] == "queued"
|
||||
|
||||
def test_list_jobs_empty_initially(self, client):
|
||||
resp = client.get("/api/score/jobs")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["jobs"] == []
|
||||
|
||||
def test_get_unknown_job_returns_404(self, client):
|
||||
resp = client.get("/api/score/jobs/nonexistent123")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_submitted_job_appears_in_list(self, client):
|
||||
with patch("webapp.services.score_job_manager.ScoreJobManager._run"):
|
||||
resp = client.post("/api/score/async", json={
|
||||
"question": "q?", "answer": "a.", "metrics": ["answer_relevancy"],
|
||||
})
|
||||
job_id = resp.json()["job_id"]
|
||||
time.sleep(0.1)
|
||||
list_resp = client.get("/api/score/jobs")
|
||||
ids = [j["job_id"] for j in list_resp.json()["jobs"]]
|
||||
assert job_id in ids
|
||||
|
||||
def test_get_job_by_id_returns_status(self, client):
|
||||
with patch("webapp.services.score_job_manager.ScoreJobManager._run"):
|
||||
resp = client.post("/api/score/async", json={
|
||||
"question": "q?", "answer": "a.", "metrics": ["answer_relevancy"],
|
||||
})
|
||||
job_id = resp.json()["job_id"]
|
||||
time.sleep(0.1)
|
||||
get_resp = client.get(f"/api/score/jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["job_id"] == job_id
|
||||
|
||||
def test_missing_required_fields_returns_422(self, client):
|
||||
resp = client.post("/api/score/async", json={"question": "q?"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestScoreJobManager:
|
||||
def test_completed_job_persisted_to_index(self, tmp_path):
|
||||
"""Completed job writes index JSON."""
|
||||
from webapp.services.score_job_manager import ScoreJobManager
|
||||
from webapp.models import ScoreRequest
|
||||
|
||||
mgr = ScoreJobManager(
|
||||
output_dir=tmp_path / "runs",
|
||||
index_dir=tmp_path / "index",
|
||||
max_workers=1,
|
||||
)
|
||||
req = ScoreRequest(question="q?", answer="a.", metrics=["answer_relevancy"])
|
||||
|
||||
# Patch _run directly — it uses lazy imports internally
|
||||
def fake_run(job_id, request):
|
||||
mgr._update(job_id, status="completed", finished_at="2026-01-01T00:00:01+00:00",
|
||||
run_id="fake-run-id", scores={"answer_relevancy": 0.85},
|
||||
weighted_score=0.85, latency_ms=500)
|
||||
|
||||
with patch.object(mgr, "_run", side_effect=fake_run):
|
||||
status = mgr.submit(req)
|
||||
|
||||
for _ in range(20):
|
||||
s = mgr.get(status.job_id)
|
||||
if s and s.status == "completed":
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
s = mgr.get(status.job_id)
|
||||
assert s is not None
|
||||
idx_path = tmp_path / "index" / f"{status.job_id}.json"
|
||||
assert idx_path.exists()
|
||||
data = json.loads(idx_path.read_text(encoding="utf-8"))
|
||||
assert data["job_id"] == status.job_id
|
||||
assert data["status"] == "completed"
|
||||
|
||||
def test_loads_existing_index_on_startup(self, tmp_path):
|
||||
"""Manager loads persisted jobs from index dir on init."""
|
||||
from webapp.services.score_job_manager import ScoreJobManager
|
||||
from webapp.models import AsyncScoreJobStatus
|
||||
|
||||
idx_dir = tmp_path / "index"
|
||||
idx_dir.mkdir()
|
||||
fake = AsyncScoreJobStatus(
|
||||
job_id="testjob001",
|
||||
status="completed",
|
||||
created_at="2026-01-01T00:00:00+00:00",
|
||||
run_id="some-run-id",
|
||||
scores={"answer_relevancy": 0.9},
|
||||
weighted_score=0.9,
|
||||
latency_ms=1000,
|
||||
)
|
||||
(idx_dir / "testjob001.json").write_text(
|
||||
json.dumps(fake.model_dump(), ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
mgr = ScoreJobManager(
|
||||
output_dir=tmp_path / "runs",
|
||||
index_dir=idx_dir,
|
||||
max_workers=1,
|
||||
)
|
||||
loaded = mgr.get("testjob001")
|
||||
assert loaded is not None
|
||||
assert loaded.status == "completed"
|
||||
assert loaded.run_id == "some-run-id"
|
||||
Reference in New Issue
Block a user