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:
2026-06-24 17:24:22 +08:00
parent abcd61ec8f
commit 4fd515d2d9
9 changed files with 706 additions and 11 deletions

View 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"