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:
@@ -66,6 +66,11 @@ const API = {
|
||||
},
|
||||
applyProfiles(body) { return API.post("/api/llm-profiles/apply", body); },
|
||||
|
||||
// 异步评分记录 API
|
||||
scoreJobsAsync(body) { return API.post("/api/score/async", body); },
|
||||
getScoreJob(jobId) { return API.get(`/api/score/jobs/${encodeURIComponent(jobId)}`); },
|
||||
listScoreJobs() { return API.get("/api/score/jobs"); },
|
||||
|
||||
// 测试已保存 profile 的连通性
|
||||
testProfile(id) {
|
||||
return fetch(`/api/llm-profiles/${encodeURIComponent(id)}/test`, { method: "POST" })
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
const App = {
|
||||
currentRunId: null,
|
||||
activeView: null,
|
||||
views: ["runs", "new", "report", "profiles", "apidocs"],
|
||||
titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置", apidocs: "API 文档" },
|
||||
views: ["runs", "new", "report", "profiles", "scorejobs", "apidocs"],
|
||||
titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置", scorejobs: "评分记录", apidocs: "API 文档" },
|
||||
|
||||
// 初始化:绑定导航、从 URL/sessionStorage 恢复上次位置、启动健康检查。
|
||||
init() {
|
||||
@@ -68,10 +68,11 @@ const App = {
|
||||
sessionStorage.setItem("rag_view", view);
|
||||
if (App.currentRunId) sessionStorage.setItem("rag_run_id", App.currentRunId);
|
||||
|
||||
if (view === "runs") App.loadRuns();
|
||||
if (view === "new") Runner.loadScenarios();
|
||||
if (view === "report") Report.render(App.currentRunId);
|
||||
if (view === "profiles") Profiles.load();
|
||||
if (view === "runs") App.loadRuns();
|
||||
if (view === "new") Runner.loadScenarios();
|
||||
if (view === "report") Report.render(App.currentRunId);
|
||||
if (view === "profiles") Profiles.load();
|
||||
if (view === "scorejobs") ScoreJobs.load();
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
125
webapp/static/js/score_jobs.js
Normal file
125
webapp/static/js/score_jobs.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// score_jobs.js — 评分记录页面(异步 RAGAS 评分任务列表)
|
||||
// 每条评分完成后自动写入标准 Run 产物,点击「查看报告」复用现有报告详情页。
|
||||
|
||||
const ScoreJobs = {
|
||||
_pollTimers: {}, // job_id -> setInterval handle
|
||||
|
||||
async load() {
|
||||
const list = document.getElementById("scorejobs-list");
|
||||
const empty = document.getElementById("scorejobs-empty");
|
||||
list.innerHTML = '<p class="muted">加载中…</p>';
|
||||
try {
|
||||
const data = await API.listScoreJobs();
|
||||
const jobs = data.jobs || [];
|
||||
list.innerHTML = "";
|
||||
if (jobs.length === 0) {
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
jobs.forEach(job => list.appendChild(ScoreJobs.renderCard(job)));
|
||||
// Auto-poll any pending jobs
|
||||
jobs.forEach(job => {
|
||||
if (job.status === "queued" || job.status === "running") {
|
||||
ScoreJobs._startPoll(job.job_id);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
list.innerHTML = `<p class="muted">加载失败:${App.escape(err.message)}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderCard(job) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "run-card";
|
||||
card.id = `score-job-${job.job_id}`;
|
||||
card.innerHTML = ScoreJobs._cardHtml(job);
|
||||
// Bind report button if already completed
|
||||
ScoreJobs._bindReportBtn(card, job);
|
||||
return card;
|
||||
},
|
||||
|
||||
_cardHtml(job) {
|
||||
const time = App.shortTime(job.created_at);
|
||||
const question = App.escape((job.request_summary?.question || "—").slice(0, 60));
|
||||
const metrics = (job.request_summary?.metrics || []).join(", ");
|
||||
|
||||
const statusBadge = `<span class="badge ${job.status}">${job.status}</span>`;
|
||||
|
||||
let scoreHtml = "";
|
||||
if (job.status === "completed") {
|
||||
scoreHtml = Object.entries(job.scores || {})
|
||||
.map(([k, v]) => {
|
||||
const cls = App.scoreClass(v);
|
||||
const text = v === null || v === undefined ? "n/a" : Number(v).toFixed(3);
|
||||
return `<span class="metric-chip" title="${App.escape(k)}">${App.escape(App.shortMetric(k))} <b class="${cls}">${text}</b></span>`;
|
||||
})
|
||||
.join(" ");
|
||||
if (job.weighted_score !== null && job.weighted_score !== undefined) {
|
||||
const cls = App.scoreClass(job.weighted_score);
|
||||
scoreHtml += ` <span class="metric-chip">综合 <b class="${cls}">${Number(job.weighted_score).toFixed(3)}</b></span>`;
|
||||
}
|
||||
} else if (job.status === "failed") {
|
||||
scoreHtml = `<span style="color:var(--bad);font-size:12px">${App.escape((job.error || "").slice(0, 80))}</span>`;
|
||||
} else {
|
||||
scoreHtml = `<span class="muted">评分中,请稍候…</span>`;
|
||||
}
|
||||
|
||||
const reportBtn = job.status === "completed" && job.run_id
|
||||
? `<button class="btn btn-sm btn-primary score-job-report-btn" data-run-id="${App.escape(job.run_id)}">查看报告</button>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="run-card-head">
|
||||
<div class="run-card-title">${question}</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">${statusBadge}${reportBtn}</div>
|
||||
</div>
|
||||
<div class="run-card-meta">
|
||||
<div>指标:${App.escape(metrics)} · ${time} · ${job.latency_ms}ms</div>
|
||||
</div>
|
||||
<div class="run-card-metrics">${scoreHtml}</div>
|
||||
`;
|
||||
},
|
||||
|
||||
_bindReportBtn(card, job) {
|
||||
const btn = card.querySelector(".score-job-report-btn");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
const runId = btn.dataset.runId;
|
||||
if (runId) {
|
||||
App.enableReportNav();
|
||||
App.navigate("report", runId);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_startPoll(jobId) {
|
||||
if (ScoreJobs._pollTimers[jobId]) return;
|
||||
ScoreJobs._pollTimers[jobId] = setInterval(async () => {
|
||||
try {
|
||||
const job = await API.getScoreJob(jobId);
|
||||
const card = document.getElementById(`score-job-${jobId}`);
|
||||
if (card) {
|
||||
card.innerHTML = ScoreJobs._cardHtml(job);
|
||||
ScoreJobs._bindReportBtn(card, job);
|
||||
}
|
||||
if (job.status === "completed" || job.status === "failed") {
|
||||
clearInterval(ScoreJobs._pollTimers[jobId]);
|
||||
delete ScoreJobs._pollTimers[jobId];
|
||||
// If completed, pre-enable report nav
|
||||
if (job.status === "completed" && job.run_id) {
|
||||
App.enableReportNav();
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
clearInterval(ScoreJobs._pollTimers[jobId]);
|
||||
delete ScoreJobs._pollTimers[jobId];
|
||||
}
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
stopAllPolls() {
|
||||
Object.values(ScoreJobs._pollTimers).forEach(t => clearInterval(t));
|
||||
ScoreJobs._pollTimers = {};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user