From 4eb09e983128f5cc8c6bb91135ba7f26e0862f05 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 16:43:11 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E8=B7=A8=E5=88=86?= =?UTF-8?q?=E7=BB=84=E8=B4=A6=E5=8F=B7=E8=AF=84=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/cli.ts | 53 ++++++++++++ src/account-score-aggregation.ts | 134 +++++++++++++++++++++++++++++++ src/account-score-service.ts | 7 +- static/app.js | 42 ++++++++-- static/scores.html | 6 +- static/styles.css | 11 ++- 6 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/account-score-aggregation.ts diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 0e72edc..1b0ed54 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -1,4 +1,5 @@ import { AdminHttpClient } from "../../src/admin-http-client"; +import { mergeAccountScores } from "../../src/account-score-aggregation"; import { createEmbeddedContext } from "../../src/bootstrap"; import { loadConfig, type EmbeddedCliTarget, type HttpCliTarget } from "../../src/config"; @@ -13,6 +14,10 @@ interface Parsed { draws: number | null; } +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + function value(args: string[], name: string): string | null { const index = args.indexOf(name); if (index < 0) return null; @@ -60,6 +65,7 @@ function help(): Record { commands: [ "config validate", "backend check", + "scores aggregate-smoke", "lottery status", "lottery draw [--confirm]", "lottery reset [--draws N] [--include-records] --confirm", @@ -156,6 +162,53 @@ export async function runCli(args: string[]): Promise { }, null, 2)); return; } + if (parsed.command.join(" ") === "scores aggregate-smoke") { + const account = (groupId: number, groupName: string, successRequests: number, failureRequests: number, ttftP95Ms: number, apiAmountUsd: number) => ({ + accountId: 15, + accountName: "lyon9801 0.0", + groupId, + groupName, + status: "active", + currentlyAvailable: true, + priority: 1, + successRequests, + failureRequests, + observedAttempts: successRequests + failureRequests, + streamSuccessRequests: successRequests, + firstTokenSamples: successRequests, + ttftP95Ms, + usage: { requestCount: successRequests, tokenCount: successRequests * 1000, apiAmountUsd }, + }); + const rows = mergeAccountScores([ + account(3, "自用", 100, 0, 10_591, 1), + account(2, "unidesk-codex-pool", 50, 1, 13_206, 0.5), + ]); + const row = rows[0] ?? {}; + const checks = { + uniqueAccount: rows.length === 1, + groupsMerged: Array.isArray(row.groupNames) && row.groupNames.length === 2, + attemptsMerged: row.observedAttempts === 151, + usageMerged: record(row.usage)?.requestCount === 150 && record(row.usage)?.apiAmountUsd === 1.5, + decimalScore: typeof row.score === "number" && !Number.isInteger(row.score), + conservativeTtft: row.ttftP95Ms === 13_206, + }; + console.log(JSON.stringify({ + ok: Object.values(checks).every(Boolean), + action: "account-score-aggregate-smoke", + checks, + result: { + accountId: row.accountId, + accountName: row.accountName, + groupNames: row.groupNames, + observedAttempts: row.observedAttempts, + score: row.score, + ttftP95Ms: row.ttftP95Ms, + usage: row.usage, + }, + mutation: false, + }, null, 2)); + return; + } const targetId = parsed.targetId ?? config.runtime.defaultCliTarget; const target = config.runtime.cliTargets[targetId]; if (!target) throw new Error(`runtime.cliTargets.${targetId} does not exist`); diff --git a/src/account-score-aggregation.ts b/src/account-score-aggregation.ts new file mode 100644 index 0000000..6c20d92 --- /dev/null +++ b/src/account-score-aggregation.ts @@ -0,0 +1,134 @@ +type Row = Record; + +function record(value: unknown): Row | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Row : null; +} + +function numeric(value: unknown): number | null { + const parsed = Number(value); + return value !== null && value !== undefined && Number.isFinite(parsed) ? parsed : null; +} + +function sum(rows: Row[], key: string): number { + return rows.reduce((total, row) => total + (numeric(row[key]) ?? 0), 0); +} + +function sumPresent(rows: Row[], key: string): number | null { + const values = rows.flatMap((row) => { + const value = numeric(row[key]); + return value === null ? [] : [value]; + }); + return values.length > 0 ? values.reduce((total, value) => total + value, 0) : null; +} + +function max(rows: Row[], key: string): number | null { + const values = rows.flatMap((row) => { + const value = numeric(row[key]); + return value === null ? [] : [value]; + }); + return values.length > 0 ? Math.max(...values) : null; +} + +function accountKey(row: Row): string { + if (row.accountId !== null && row.accountId !== undefined) return `id:${String(row.accountId)}`; + return `name:${String(row.accountName ?? "").trim().toLowerCase()}`; +} + +function reliabilityPoints(failureRate: number | null): number | null { + return failureRate === null ? null : Math.round(60 * (1 - Math.min(Math.max(failureRate, 0), 0.2) / 0.2) * 100) / 100; +} + +function latencyPoints(ttftP95Ms: number | null): number | null { + return ttftP95Ms === null ? null : Math.round(25 * (1 - Math.min(Math.max(ttftP95Ms - 10_000, 0), 170_000) / 170_000) * 100) / 100; +} + +function scoreGrade(score: number | null, comparable: boolean, observedAttempts: number): string { + if (score === null || (!comparable && !(score < 60 && observedAttempts >= 10))) return "insufficient"; + return score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "E"; +} + +function assessment(grade: string): string { + return ({ A: "preferred", B: "healthy", C: "watch", D: "degraded", E: "poor" } as Record)[grade] ?? "insufficient-evidence"; +} + +export function mergeAccountScores(rows: Row[]): Row[] { + const grouped = new Map(); + for (const row of rows) grouped.set(accountKey(row), [...(grouped.get(accountKey(row)) ?? []), row]); + + return [...grouped.values()].map((accountRows) => { + const representative = accountRows[0]!; + const groupIds = [...new Set(accountRows.flatMap((row) => Array.isArray(row.groupIds) ? row.groupIds : [row.groupId]).filter((value) => value !== null && value !== undefined))]; + const groupNames = [...new Set(accountRows.flatMap((row) => Array.isArray(row.groupNames) ? row.groupNames.map(String) : [String(row.groupName ?? "")]).map((value) => value.trim()).filter(Boolean))]; + const usages = accountRows.map((row) => record(row.usage) ?? {}); + const successRequests = sum(accountRows, "successRequests"); + const failureRequests = sum(accountRows, "failureRequests"); + const observedAttempts = successRequests + failureRequests; + const failureRate = observedAttempts > 0 ? Math.round(failureRequests / observedAttempts * 1_000_000) / 1_000_000 : null; + const firstTokenSamples = sum(accountRows, "firstTokenSamples"); + const streamSuccessRequests = sum(accountRows, "streamSuccessRequests"); + const ttftP95Ms = max(accountRows, "ttftP95Ms"); + const reliability = reliabilityPoints(failureRate); + const latency = firstTokenSamples >= 5 ? latencyPoints(ttftP95Ms) : null; + const currentlyAvailable = accountRows.every((row) => row.currentlyAvailable === true); + const status = String(representative.status ?? ""); + const availability = currentlyAvailable ? 15 : status === "active" ? 8 : 0; + const availableWeight = (reliability === null ? 0 : 60) + (latency === null ? 0 : 25) + 15; + const earned = (reliability ?? 0) + (latency ?? 0) + availability; + const score = observedAttempts > 0 && availableWeight > 0 ? Math.round(earned / availableWeight * 1_000) / 10 : null; + const comparable = observedAttempts >= 10 && firstTokenSamples >= 5; + const grade = scoreGrade(score, comparable, observedAttempts); + const reasons = [...new Set(accountRows.flatMap((row) => Array.isArray(row.reasons) ? row.reasons.map(String) : []))]; + + return { + ...representative, + groupId: groupIds.length === 1 ? groupIds[0] : null, + groupName: groupNames.join(" / "), + groupIds, + groupNames, + currentlyAvailable, + score, + grade, + assessment: assessment(grade), + confidence: observedAttempts >= 50 && firstTokenSamples >= 20 ? "high" : observedAttempts >= 10 && firstTokenSamples >= 5 ? "medium" : "low", + scoreComparable: comparable, + observedAttempts, + successRequests, + failureRequests, + failureRate, + streamSuccessRequests, + firstTokenSamples, + firstTokenCoverage: streamSuccessRequests > 0 ? Math.round(firstTokenSamples / streamSuccessRequests * 1_000_000) / 1_000_000 : null, + ttftP95Ms, + customerErrorRequests: sum(accountRows, "customerErrorRequests"), + scoreableUpstreamErrorRequests: sum(accountRows, "scoreableUpstreamErrorRequests"), + excludedNonUpstreamErrorRequests: sum(accountRows, "excludedNonUpstreamErrorRequests"), + failoverRequests: sum(accountRows, "failoverRequests"), + failoverRecovered: sum(accountRows, "failoverRecovered"), + failoverFailed: sum(accountRows, "failoverFailed"), + failoverOutcomeMissing: sum(accountRows, "failoverOutcomeMissing"), + sameAccountRetryEvents: sum(accountRows, "sameAccountRetryEvents"), + tempUnschedulableEvents: sum(accountRows, "tempUnschedulableEvents"), + forwardFailedRequests: sum(accountRows, "forwardFailedRequests"), + reasons, + usage: { + ...usages[0], + requestCount: sumPresent(usages, "requestCount"), + tokenCount: sumPresent(usages, "tokenCount"), + apiAmountUsd: (() => { + const value = sumPresent(usages, "apiAmountUsd"); + return value === null ? null : Math.round(value * 100_000_000) / 100_000_000; + })(), + upstreamCostCny: (() => { + const value = sumPresent(usages, "upstreamCostCny"); + return value === null ? null : Math.round(value * 100_000_000) / 100_000_000; + })(), + }, + scoreComponents: { reliability, latency, availability, availableWeight }, + aggregation: { + scope: "unique-account-across-groups", + groupCount: groupNames.length, + latency: "maximum-group-ttft-p95", + }, + }; + }).sort((left, right) => Number(right.score ?? -1) - Number(left.score ?? -1)); +} diff --git a/src/account-score-service.ts b/src/account-score-service.ts index 71e94c7..1fb797f 100644 --- a/src/account-score-service.ts +++ b/src/account-score-service.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { mergeAccountScores } from "./account-score-aggregation"; import type { AppConfig } from "./config"; interface ScoreSnapshot { @@ -90,12 +91,12 @@ export class AccountScoreService { }; }); const refreshedAt = new Date(); - const accounts: Array> = details.flatMap(({ group, accounts }) => accounts.map((account): Record => ({ + const accounts = mergeAccountScores(details.flatMap(({ group, accounts }) => accounts.map((account): Record => ({ groupId: group.groupId ?? null, groupName: group.groupName ?? null, platform: group.platform ?? null, ...account, - }))).sort((left, right) => Number(right.score ?? -1) - Number(left.score ?? -1)); + })))); this.snapshot = { ok: true, status: "ready", @@ -170,7 +171,7 @@ export class AccountScoreService { if (existsSync(this.cachePath)) { try { const cached = record(JSON.parse(readFileSync(this.cachePath, "utf8"))) as ScoreSnapshot | null; - if (cached) return { ...cached, status: "stale" }; + if (cached) return { ...cached, status: "stale", accounts: mergeAccountScores(records(cached.accounts)) }; } catch { // Invalid cache is replaced by the next successful refresh. } diff --git a/static/app.js b/static/app.js index c14ba6b..8c5d65f 100644 --- a/static/app.js +++ b/static/app.js @@ -87,14 +87,36 @@ async function loginPage() { } let scoreRows = [] +let scoreRefreshedAt = null +let scoreNextRefreshAt = null function gradeClass(value) { const grade = String(value ?? '').toLowerCase() - if (grade === 'excellent' || grade === 'good') return 'grade-good' - if (grade === 'poor' || grade === 'critical' || grade === 'insufficient') return 'grade-risk' + if (grade === 'a' || grade === 'b' || grade === 'excellent' || grade === 'good') return 'grade-good' + if (grade === 'd' || grade === 'e' || grade === 'poor' || grade === 'critical' || grade === 'insufficient') return 'grade-risk' return 'grade-mid' } +function groupLabels(row) { + const groups = Array.isArray(row.groupNames) && row.groupNames.length ? row.groupNames : [row.groupName].filter(Boolean) + return `
${groups.map((group) => `${escapeHtml(group)}`).join('')}
` +} + +function countdown(value) { + if (!value) return '--:--' + const remaining = Math.max(0, new Date(value).getTime() - Date.now()) + if (remaining === 0) return '等待刷新' + const totalSeconds = Math.ceil(remaining / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` +} + +function renderRefreshClock() { + $('#score-updated-time').textContent = scoreRefreshedAt ? `北京时间 ${time(scoreRefreshedAt)}` : '尚无成功快照' + $('#score-countdown').textContent = countdown(scoreNextRefreshAt) +} + function renderScoreRows() { const term = ($('#score-filter')?.value ?? '').trim().toLowerCase() const rows = scoreRows.filter((row) => `${row.accountName} ${row.groupName}`.toLowerCase().includes(term)) @@ -102,9 +124,9 @@ function renderScoreRows() { const usage = row.usage ?? {} return ` ${escapeHtml(row.accountName)}#${escapeHtml(row.accountId)} - ${escapeHtml(row.groupName)} + ${groupLabels(row)} ${number(row.priority)} - ${number(row.score)} + ${number(row.score, 1)} ${escapeHtml(row.grade ?? '—')} ${escapeHtml(row.confidence ?? '—')} ${number(row.observedAttempts)} @@ -128,7 +150,9 @@ function renderScores(data) { $('#metric-window').textContent = data.window ?? '—' $('#score-state').textContent = ({ ready: '已更新', refreshing: '刷新中', stale: '使用旧快照', unavailable: '暂无快照' })[data.status] ?? data.status $('#score-state').dataset.state = data.status - $('#score-updated').textContent = data.refreshedAt ? `北京时间 ${time(data.refreshedAt)} · 下次 ${time(data.nextRefreshAt)}` : (data.error ?? '尚无成功快照') + scoreRefreshedAt = data.refreshedAt ?? scoreRefreshedAt + scoreNextRefreshAt = data.nextRefreshAt ?? scoreNextRefreshAt + renderRefreshClock() renderScoreRows() } @@ -139,12 +163,16 @@ async function scoresPage() { button.disabled = true $('#score-state').textContent = '刷新中' try { renderScores(await requestJson('/api/scores/refresh', { method: 'POST', body: '{}' }, 200000)) } - catch (error) { $('#score-updated').textContent = error instanceof Error ? error.message : String(error) } + catch (error) { $('#score-updated-time').textContent = error instanceof Error ? error.message : String(error) } finally { button.disabled = false } }) renderScores(await requestJson('/api/scores')) + setInterval(renderRefreshClock, 1000) setInterval(async () => { - if (!document.hidden) renderScores(await requestJson('/api/scores').catch(() => ({ ok: false, accounts: scoreRows }))) + if (!document.hidden) { + const data = await requestJson('/api/scores').catch(() => null) + if (data) renderScores(data) + } }, 30000) } diff --git a/static/scores.html b/static/scores.html index ce70512..54a44c0 100644 --- a/static/scores.html +++ b/static/scores.html @@ -28,7 +28,11 @@
筛选
-

尚无成功快照

+
+ 上次更新时间 + 尚无成功快照 + 距离下次更新 --:-- +
diff --git a/static/styles.css b/static/styles.css index 6c77985..e974a87 100644 --- a/static/styles.css +++ b/static/styles.css @@ -54,7 +54,11 @@ button:disabled { cursor: wait; opacity: .48; } .table-section { padding-top: 20px; } .table-toolbar { min-height: 48px; display: flex; justify-content: space-between; align-items: center; gap: 18px; } -.table-toolbar p { color: var(--muted); font: 11px/1 var(--mono); } +.refresh-clock { min-width: 310px; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 4px 18px; padding: 10px 14px; border-left: 3px solid var(--signal); background: var(--surface); } +.refresh-clock > span { grid-column: 1 / -1; color: var(--muted); font-size: 10px; } +.refresh-clock strong { color: var(--paper); font: 650 16px/1.2 var(--mono); } +.refresh-clock small { color: var(--muted); font: 10px/1.2 var(--mono); white-space: nowrap; } +.refresh-clock b { color: var(--signal); font-size: 14px; } .filter-field { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; } .filter-field input { width: 260px; height: 34px; border: 1px solid var(--line); border-radius: 3px; background: var(--surface); color: var(--paper); padding: 0 10px; outline: 0; } .filter-field input:focus { border-color: var(--signal); } @@ -66,7 +70,9 @@ button:disabled { cursor: wait; opacity: .48; } .account-cell { min-width: 230px; } .account-cell b { display: block; max-width: 360px; overflow: hidden; text-overflow: ellipsis; } .account-cell small { display: block; color: var(--muted); margin-top: 4px; font: 10px/1 var(--mono); } -.score-value { display: inline-grid; place-items: center; width: 34px; height: 26px; border-radius: 3px; font: 700 12px/1 var(--mono); } +.score-value { display: inline-grid; place-items: center; min-width: 44px; height: 26px; padding: 0 6px; border-radius: 3px; font: 700 12px/1 var(--mono); } +.group-list { display: flex; flex-wrap: wrap; gap: 4px; max-width: 250px; } +.group-list span { padding: 3px 6px; border: 1px solid var(--line); border-radius: 3px; color: var(--muted); font: 10px/1.2 var(--mono); } .grade-good { color: var(--signal); background: rgba(185,228,71,.1); } .grade-mid { color: var(--info); background: rgba(112,183,212,.1); } .grade-risk { color: var(--alert); background: rgba(255,107,74,.1); } @@ -126,6 +132,7 @@ button:disabled { cursor: wait; opacity: .48; } .metric-strip { grid-template-columns: repeat(2, 1fr); overflow: hidden; } .metric-strip > div { border-bottom: 1px solid var(--line); padding-left: 12px; } .table-toolbar { align-items: stretch; flex-direction: column; padding-bottom: 12px; } + .refresh-clock { min-width: 0; width: 100%; } .filter-field input { flex: 1; width: auto; } .lottery-grid { grid-template-columns: 1fr; } .draw-console { border-right: 0; border-bottom: 1px solid var(--line); padding: 36px 0; }