Merge pull request #7 from pikasTech/fix/account-score-dedupe
账号评分按上游账号跨分组聚合
This commit is contained in:
@@ -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<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : 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<string, unknown> {
|
||||
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<void> {
|
||||
}, 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`);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
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<string, string>)[grade] ?? "insufficient-evidence";
|
||||
}
|
||||
|
||||
export function mergeAccountScores(rows: Row[]): Row[] {
|
||||
const grouped = new Map<string, Row[]>();
|
||||
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));
|
||||
}
|
||||
@@ -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<Record<string, unknown>> = details.flatMap(({ group, accounts }) => accounts.map((account): Record<string, unknown> => ({
|
||||
const accounts = mergeAccountScores(details.flatMap(({ group, accounts }) => accounts.map((account): Record<string, unknown> => ({
|
||||
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.
|
||||
}
|
||||
|
||||
+35
-7
@@ -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 `<div class="group-list">${groups.map((group) => `<span>${escapeHtml(group)}</span>`).join('')}</div>`
|
||||
}
|
||||
|
||||
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 `<tr>
|
||||
<td class="account-cell"><b>${escapeHtml(row.accountName)}</b><small>#${escapeHtml(row.accountId)}</small></td>
|
||||
<td>${escapeHtml(row.groupName)}</td>
|
||||
<td>${groupLabels(row)}</td>
|
||||
<td>${number(row.priority)}</td>
|
||||
<td><span class="score-value ${gradeClass(row.grade)}">${number(row.score)}</span></td>
|
||||
<td><span class="score-value ${gradeClass(row.grade)}">${number(row.score, 1)}</span></td>
|
||||
<td>${escapeHtml(row.grade ?? '—')}</td>
|
||||
<td>${escapeHtml(row.confidence ?? '—')}</td>
|
||||
<td>${number(row.observedAttempts)}</td>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -28,7 +28,11 @@
|
||||
<section class="table-section">
|
||||
<div class="table-toolbar">
|
||||
<div class="filter-field"><span>筛选</span><input id="score-filter" type="search" placeholder="账号或分组" /></div>
|
||||
<p id="score-updated">尚无成功快照</p>
|
||||
<div id="score-updated" class="refresh-clock">
|
||||
<span>上次更新时间</span>
|
||||
<strong id="score-updated-time">尚无成功快照</strong>
|
||||
<small>距离下次更新 <b id="score-countdown">--:--</b></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" aria-label="所有分组账号评分">
|
||||
|
||||
+9
-2
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user