dsh-agent-board 1.7.4 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -14
- package/index.mjs +9 -1
- package/lib/client/board-list.js +37 -26
- package/lib/client/dashboard.js +407 -67
- package/lib/client/kernel.js +23 -10
- package/lib/client/task-detail.js +21 -2
- package/lib/client.js +488 -105
- package/lib/core.mjs +202 -8
- package/lib/dispatch.mjs +54 -7
- package/lib/health.mjs +68 -1
- package/lib/rpc.mjs +86 -16
- package/lib/usage.mjs +608 -0
- package/package.json +2 -2
- package/test/core.test.mjs +648 -70
- package/test/headless-smoke.test.mjs +209 -0
- package/test/helpers/mock-ctx.mjs +327 -0
- package/test/integration.test.mjs +543 -0
- package/test/quality-alert-digest.test.mjs +128 -0
- package/test/runtime-health.test.mjs +158 -0
- package/test/scoreboard-client.test.mjs +106 -0
- package/test/scoreboard-trends-client.test.mjs +104 -0
- package/test/scoreboard.test.mjs +271 -0
- package/test/scorecard-clusters.test.mjs +225 -0
- package/test/scorecard-data.test.mjs +214 -0
- package/test/usage-mainwindow.test.mjs +7 -5
- package/test/user-guide-client.test.mjs +79 -0
package/lib/usage.mjs
CHANGED
|
@@ -516,3 +516,611 @@ export function readMainWindowUsage(sessionId, sessionsRoot, range, cacheStore)
|
|
|
516
516
|
return null
|
|
517
517
|
}
|
|
518
518
|
}
|
|
519
|
+
|
|
520
|
+
// ===== 记分卡数据底座(task-muxhshfu 卡1):run 完备性体检 / 驳回归因 / 场景分桶 =====
|
|
521
|
+
// 三个 helper 全部纯函数零 IO(不碰 fs/不读时钟——日期只用 Date 解析已有字符串),供卡2聚合底座直接组合。
|
|
522
|
+
// 数据源是任务级 t.runs[] 留账(写路径见 dispatch.mjs recordRunHistory/closeRunHistory/accumulateRunUsage):
|
|
523
|
+
// 新形态条目 { role, id, at, model, outcome, endedAt, usage:{五分量}, usageSeq, usageRecorded, continuable?, resume? }
|
|
524
|
+
// 老形态条目可能缺 model/endedAt/usage——聚合端退化口径在此收口(标缺失,绝不伪造)。
|
|
525
|
+
|
|
526
|
+
// 规模段边界常量(v1 写死):按任务有效 token(effectiveTokens 口径:输入+输出+缓存写,不含缓存读)分桶——
|
|
527
|
+
// 小 <1M / 中 1M~10M / 大 >10M。边界归属:恰好 1M 归中、恰好 10M 归中(「<1M」「>10M」都是严格不等号)。
|
|
528
|
+
export var SIZE_BUCKET_SMALL_MAX = 1000000
|
|
529
|
+
export var SIZE_BUCKET_MEDIUM_MAX = 10000000
|
|
530
|
+
// 有效 token → 规模段('small' | 'medium' | 'large')。
|
|
531
|
+
// 非法/非正输入 → null:任务还没结算出有效消耗时「规模不可知」,调用方跳过该样本,不硬塞进小桶拉低口径。
|
|
532
|
+
export function sizeBucketOf(effTok) {
|
|
533
|
+
var n = Number(effTok)
|
|
534
|
+
if (!isFinite(n) || n <= 0) return null
|
|
535
|
+
if (n < SIZE_BUCKET_SMALL_MAX) return 'small'
|
|
536
|
+
if (n <= SIZE_BUCKET_MEDIUM_MAX) return 'medium'
|
|
537
|
+
return 'large'
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 角色归一(场景桶的另一维):run.role 原始值 → 'worker' | 'verifier' | 'other'。
|
|
541
|
+
// hook-pre/hook-post/缺省/未知一律 'other'——不计入 worker/verifier 对比桶,也不假装成 worker
|
|
542
|
+
//(老卡 runs[] 缺 role 时同理:宁可丢进 other,不污染两个主桶的驳回率/耗时口径)。
|
|
543
|
+
export function runRoleOf(r) {
|
|
544
|
+
var role = r && r.role
|
|
545
|
+
if (role === 'worker') return 'worker'
|
|
546
|
+
if (role === 'verifier') return 'verifier'
|
|
547
|
+
return 'other'
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// run 条目完备性体检(卡1①):continuable 时代一条「已落定」的 run 应齐 model/at/endedAt/outcome/usage
|
|
551
|
+
// 五组分(usage 以 total>0 为一次有效结算,与 runUsageOf 同口径;分量缺省视为 0,老形态只有 total 不判缺)。
|
|
552
|
+
// · outcome==='running'(在飞/手动终止残留):只核 at——model 要等结算时回填、endedAt/usage 结算才落,
|
|
553
|
+
// 它不是「缺字段」而是「还没结算」(手动终止刻意不关账,留 running = 不留续跑资格);
|
|
554
|
+
// · resume 是稀疏旗标(仅续跑记录置 true):缺省即 false,永不计入 missing;
|
|
555
|
+
// · 已落定 run 的 model 为 '' = 缺口:结算路径现已回填日志模型名(dispatch.accumulateRunUsage 补落账),
|
|
556
|
+
// 存量老数据仍会被这里如实量出——这正是体检要暴露的退化面。
|
|
557
|
+
// 返回 { status: 'complete' | 'running' | 'incomplete', missing: [字段名...] }(missing 仅 incomplete 时非空)。
|
|
558
|
+
export function auditRunEntry(r) {
|
|
559
|
+
if (!r || typeof r !== 'object') return { status: 'incomplete', missing: ['entry'] }
|
|
560
|
+
var missing = []
|
|
561
|
+
if (!r.at || isNaN(new Date(r.at).getTime())) missing.push('at')
|
|
562
|
+
var oc = String(r.outcome || '')
|
|
563
|
+
if (!oc) { missing.push('outcome'); return { status: 'incomplete', missing: missing } }
|
|
564
|
+
if (oc === 'running') return { status: missing.length ? 'incomplete' : 'running', missing: missing }
|
|
565
|
+
if (!r.model) missing.push('model')
|
|
566
|
+
if (!r.endedAt || isNaN(new Date(r.endedAt).getTime())) missing.push('endedAt')
|
|
567
|
+
var u = r.usage
|
|
568
|
+
if (!u || typeof u !== 'object' || numOr0(u.total) <= 0) missing.push('usage')
|
|
569
|
+
return { status: missing.length ? 'incomplete' : 'complete', missing: missing }
|
|
570
|
+
}
|
|
571
|
+
// 板级体检汇总:逐 run 过 auditRunEntry,聚合出完备/在飞/缺字段三档计数 + 逐字段缺口分布。
|
|
572
|
+
// 纯体检不修改任何数据(补字段走写路径回填,不在这里改存量)。runs 明细供排查定位(taskId/runId/缺哪些)。
|
|
573
|
+
export function auditRunsCompleteness(tasks) {
|
|
574
|
+
var s = { total: 0, complete: 0, running: 0, incomplete: 0, byField: { model: 0, at: 0, endedAt: 0, outcome: 0, usage: 0, entry: 0 }, runs: [] }
|
|
575
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
576
|
+
for (var i = 0; i < list.length; i++) {
|
|
577
|
+
var t = list[i]
|
|
578
|
+
var trs = (t && Array.isArray(t.runs)) ? t.runs : []
|
|
579
|
+
for (var ri = 0; ri < trs.length; ri++) {
|
|
580
|
+
var r = trs[ri]
|
|
581
|
+
var a = auditRunEntry(r)
|
|
582
|
+
s.total++
|
|
583
|
+
if (a.status === 'complete') s.complete++
|
|
584
|
+
else if (a.status === 'running') s.running++
|
|
585
|
+
else s.incomplete++
|
|
586
|
+
for (var mi = 0; mi < a.missing.length; mi++) {
|
|
587
|
+
var f = a.missing[mi]
|
|
588
|
+
if (s.byField[f] === undefined) s.byField[f] = 0
|
|
589
|
+
s.byField[f]++
|
|
590
|
+
}
|
|
591
|
+
s.runs.push({ taskId: String(t && t.id || ''), runId: String(r && r.id || ''), role: runRoleOf(r), status: a.status, missing: a.missing })
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return s
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ===== 驳回归因(卡1②):rejected 落定时刻 ↔ 该任务最近的 worker run =====
|
|
598
|
+
// 三条驳回来源在这里统一收口——它们都把驳回时刻落在 t.verification.at / messages(kind='rejection').at:
|
|
599
|
+
// ① verifier 文本通道(settleVerifier:verification.by = verifier 自己的 run id)
|
|
600
|
+
// ② board_verdict 工具通道(by = verifier 会话 actor)
|
|
601
|
+
// ③ 主窗口 task_verify / 看板 verify-task(by = 主窗口 actor;direct 档任务没有 worker run → 归因 null)
|
|
602
|
+
// 归因目标恒为「被驳回的那次劳动」= 驳回时刻前最近一条 role='worker' 的 run——
|
|
603
|
+
// ⚠️ 不能直接取「时刻前最近一条 run」:verifier 自己的 run 也在 runs[] 里且时刻更晚,会自我归因。
|
|
604
|
+
// 跨天漂移:run.at 与驳回时刻不同本地日 → crossDay=true(老卡跨天驳回归因可能漂,聚合端标 ~ 不追求完美)。
|
|
605
|
+
// 返回 null = 无 worker run 可归(direct 档 / 老卡无 runs 留账);
|
|
606
|
+
// 否则 { runId, model, at, crossDay, drifted }:
|
|
607
|
+
// drifted=true 表示所有 worker run 的 at 都晚于驳回时刻(时钟回拨/老数据 at 脏)——
|
|
608
|
+
// 退化为「最新 worker run」兜底,聚合端同样按近似口径处理。
|
|
609
|
+
export function attributeRejection(t, rejectedAt) {
|
|
610
|
+
if (!t || !Array.isArray(t.runs)) return null
|
|
611
|
+
var rAt = Date.parse(rejectedAt) // 无效时刻 → NaN → 只认兜底臂
|
|
612
|
+
var best = null // at <= rejectedAt 的最近 worker run(倒序首个命中)
|
|
613
|
+
var latest = null // 无视时刻的最新 worker run(兜底)
|
|
614
|
+
for (var i = t.runs.length - 1; i >= 0; i--) {
|
|
615
|
+
var r = t.runs[i]
|
|
616
|
+
if (!r || r.role !== 'worker') continue
|
|
617
|
+
if (!latest) latest = r
|
|
618
|
+
var at = Date.parse(r.at)
|
|
619
|
+
if (!isFinite(at)) continue // at 脏的 run 不参与时刻比较,但仍是最新兜底候选
|
|
620
|
+
if (!isFinite(rAt) || at <= rAt) { best = r; break }
|
|
621
|
+
}
|
|
622
|
+
var run = best || latest
|
|
623
|
+
if (!run) return null
|
|
624
|
+
var dkRun = dayKeyOf(run.at)
|
|
625
|
+
var dkRej = dayKeyOf(rejectedAt)
|
|
626
|
+
return {
|
|
627
|
+
runId: String(run.id || ''),
|
|
628
|
+
model: String(run.model || ''),
|
|
629
|
+
at: String(run.at || ''),
|
|
630
|
+
crossDay: !!(dkRun && dkRej && dkRun !== dkRej),
|
|
631
|
+
drifted: !best
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ===== 记分卡聚合底座(task-muxhtgh9 卡2):scoreboard = 模型×角色×规模段七指标桶 + 全局质量趋势 rollup =====
|
|
636
|
+
// 设计稿 v3(笔记 n-muwsq9yztrpa)。数据源与卡1相同:任务级 t.runs[] 留账 + t.messages 驳回包
|
|
637
|
+
// (kind='rejection',三条驳回来源统一落点,pushRejection 已按 at/文本头判重)+ 落定时刻
|
|
638
|
+
// (approved 验收 = t.verifiedAt;direct/work 档直落 resolved = t.resolvedAt)。纯函数零 IO:
|
|
639
|
+
// 不碰 fs/不读时钟,日期只解析已有字符串(与卡1 helper 同一纪律)。
|
|
640
|
+
//
|
|
641
|
+
// 两层结构(增量缓存的关键):
|
|
642
|
+
// 提取层 buildScoreRecs(tasks):扫全部任务的 runs/驳回消息,产出归一化记录 —— 唯一的重活
|
|
643
|
+
// (逐 run 归一 + 驳回归因 + 日落点解析 + 任务级规模段都在这层)。
|
|
644
|
+
// 装配层 assembleScoreboard(recs, range):七指标桶 + 趋势 rollup + 范围裁剪,每次调用现算,
|
|
645
|
+
// 输入只是归一化记录(轻量数组 pass)——范围切换不触发重提取。
|
|
646
|
+
// 增量缓存:runs 只增不改 —— 缓存槽(调用方持有,与 mainWindow 尾读缓存并列在 state)形态
|
|
647
|
+
// { taskCount, stamp, recs },键 = (任务数, 最新落定时刻)。stamp 在「最新 run at/endedAt」上扩了
|
|
648
|
+
// verifiedAt / resolvedAt / 驳回消息 at / t.usage.updatedAt:驳回与验收只改 messages/status 不改 runs,
|
|
649
|
+
// usage 落账(accumulateRunUsage)只写 usage 分量与 usage.updatedAt 不回填 endedAt——不看这些会出陈账
|
|
650
|
+
// (驳回率/一次通过率/缓存命中率永远停在首次缓存值)。探针 scoreProbe 只做 Date.parse 取最大值
|
|
651
|
+
// (不解析日 key、不归因、不分配记录),比提取层便宜一个量级;命中即整份复用,提取层零重算。
|
|
652
|
+
// 样本量护栏:桶内 runs < SCOREBOARD_MIN_SAMPLE(5)标 insufficient:true(卡3 客户端据此灰显不排名)。
|
|
653
|
+
export var SCOREBOARD_MIN_SAMPLE = 5
|
|
654
|
+
|
|
655
|
+
// 探针:不分配记录地扫出 (任务数, 最新落定时刻) 缓存键。Date.parse 缺失/脏值 → NaN → 不参与 max。
|
|
656
|
+
function scoreProbeMax(stamp, v) { var n = Date.parse(v); return (isFinite(n) && n > stamp) ? n : stamp }
|
|
657
|
+
function scoreProbe(tasks) {
|
|
658
|
+
var stamp = 0
|
|
659
|
+
for (var i = 0; i < tasks.length; i++) {
|
|
660
|
+
var t = tasks[i]
|
|
661
|
+
if (!t || typeof t !== 'object') continue
|
|
662
|
+
stamp = scoreProbeMax(stamp, t.verifiedAt)
|
|
663
|
+
stamp = scoreProbeMax(stamp, t.resolvedAt)
|
|
664
|
+
if (t.usage && typeof t.usage === 'object') stamp = scoreProbeMax(stamp, t.usage.updatedAt)
|
|
665
|
+
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
666
|
+
for (var ri = 0; ri < trs.length; ri++) {
|
|
667
|
+
var r = trs[ri]
|
|
668
|
+
if (!r) continue
|
|
669
|
+
stamp = scoreProbeMax(stamp, r.at)
|
|
670
|
+
stamp = scoreProbeMax(stamp, r.endedAt)
|
|
671
|
+
}
|
|
672
|
+
var ms = Array.isArray(t.messages) ? t.messages : []
|
|
673
|
+
for (var mi = 0; mi < ms.length; mi++) {
|
|
674
|
+
if (ms[mi] && ms[mi].kind === 'rejection') stamp = scoreProbeMax(stamp, ms[mi].at)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return { count: tasks.length, stamp: stamp }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 提取层:全量扫出归一化记录(缓存未命中才跑)。
|
|
681
|
+
// runRec:{ taskId, model, role, size, day, durMs, eff, inp, cr, outcome, resume }
|
|
682
|
+
// —— 只收已落定 run(outcome 存在且非 'running';在飞/手动终止残留还没结算,不进任何指标,与卡1体检同口径)。
|
|
683
|
+
// eff/inp/cr 为 null = 该 run 无 usage 留账(老 run/未结算):进 runs 计数但不进 token/缓存口径(不伪造 0)。
|
|
684
|
+
// taskRec:{ id, resolved, resolvedDay, cardDurMs, rejectCount }
|
|
685
|
+
// —— 规模段按任务有效 token(taskEffectiveTokens 口径,卡1 sizeBucketOf 同一界线);null=规模不可知,
|
|
686
|
+
// run 仍进趋势但不进桶(不硬塞小桶拉低口径)。
|
|
687
|
+
// rejRec:{ taskId, runId, model, size, rDay }——驳回事件按卡1 attributeRejection 归到「驳回前最近 worker run」;
|
|
688
|
+
// runId='' = 无 worker run 可归(direct 档/老卡无 runs),装配层如实计 rejUnattributed,不硬塞桶。
|
|
689
|
+
function buildScoreRecs(tasks) {
|
|
690
|
+
var runRecs = [], taskRecs = [], rejRecs = []
|
|
691
|
+
var stamp = 0
|
|
692
|
+
for (var i = 0; i < tasks.length; i++) {
|
|
693
|
+
var t = tasks[i]
|
|
694
|
+
if (!t || typeof t !== 'object') continue
|
|
695
|
+
var size = sizeBucketOf(taskEffectiveTokens(t.usage).tok)
|
|
696
|
+
// 落定时刻:approved 验收 = verifiedAt(verifyApply);direct/work 档直落 resolved = resolvedAt(resolveApply)
|
|
697
|
+
var rsAt = t.verifiedAt || t.resolvedAt || ''
|
|
698
|
+
var rsMs = Date.parse(rsAt)
|
|
699
|
+
var resolved = t.status === 'resolved'
|
|
700
|
+
var resolvedDay = (resolved && isFinite(rsMs)) ? dayKeyOf(rsAt) : ''
|
|
701
|
+
stamp = scoreProbeMax(stamp, rsAt)
|
|
702
|
+
var cMs = Date.parse(t.createdAt)
|
|
703
|
+
// 卡时长 = createdAt → 落定时刻;缺任一端/倒挂 → null(不参与分布桶,不伪造)
|
|
704
|
+
var cardDurMs = (resolved && isFinite(cMs) && isFinite(rsMs) && rsMs >= cMs) ? (rsMs - cMs) : null
|
|
705
|
+
var rejCnt = numOr0(t.rejectCount)
|
|
706
|
+
var tid = String(t.id || '')
|
|
707
|
+
taskRecs.push({ id: tid, resolved: resolved, resolvedDay: resolvedDay, cardDurMs: cardDurMs, rejectCount: rejCnt })
|
|
708
|
+
if (t.usage && typeof t.usage === 'object') stamp = scoreProbeMax(stamp, t.usage.updatedAt)
|
|
709
|
+
var trs = Array.isArray(t.runs) ? t.runs : []
|
|
710
|
+
for (var ri = 0; ri < trs.length; ri++) {
|
|
711
|
+
var r = trs[ri]
|
|
712
|
+
if (!r || typeof r !== 'object') continue
|
|
713
|
+
var oc = String(r.outcome || '')
|
|
714
|
+
if (!oc || oc === 'running') continue // 在飞/手动终止残留:不算缺字段,是还没结算
|
|
715
|
+
stamp = scoreProbeMax(stamp, r.at)
|
|
716
|
+
stamp = scoreProbeMax(stamp, r.endedAt)
|
|
717
|
+
var aMs = Date.parse(r.at), eMs = Date.parse(r.endedAt)
|
|
718
|
+
var ru = runUsageOf(r) // 五分量或 null(老 run/未结算)
|
|
719
|
+
runRecs.push({
|
|
720
|
+
taskId: tid,
|
|
721
|
+
model: String(r.model || ''),
|
|
722
|
+
role: runRoleOf(r),
|
|
723
|
+
size: size,
|
|
724
|
+
day: dayKeyOf(r.at),
|
|
725
|
+
durMs: (isFinite(aMs) && isFinite(eMs) && eMs >= aMs) ? (eMs - aMs) : null,
|
|
726
|
+
eff: ru ? effectiveTokens(ru) : null,
|
|
727
|
+
inp: ru ? ru.input : null,
|
|
728
|
+
cr: ru ? ru.cacheRead : null,
|
|
729
|
+
outcome: oc,
|
|
730
|
+
resume: r.resume === true
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
// 驳回事件:三条来源统一落 t.messages kind='rejection'(判重已在写入侧),读取只认这一个落点
|
|
734
|
+
var ms = Array.isArray(t.messages) ? t.messages : []
|
|
735
|
+
for (var mi = 0; mi < ms.length; mi++) {
|
|
736
|
+
var m = ms[mi]
|
|
737
|
+
if (!m || m.kind !== 'rejection' || !m.at) continue
|
|
738
|
+
stamp = scoreProbeMax(stamp, m.at)
|
|
739
|
+
var attr = attributeRejection(t, m.at) // null = 无 worker run 可归(direct 档/老卡无 runs 留账)
|
|
740
|
+
var rDay = ''
|
|
741
|
+
if (attr) {
|
|
742
|
+
for (var ri2 = 0; ri2 < trs.length; ri2++) {
|
|
743
|
+
if (trs[ri2] && String(trs[ri2].id) === attr.runId) { rDay = dayKeyOf(trs[ri2].at); break }
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// text/mDay(卡5 驳回聚类):驳回原文与驳回消息自身日落点随 rejRec 带出——
|
|
747
|
+
// messages 只增不改(pushRejection 判重后只 push,无编辑路径),缓存键已含驳回 at,无陈账风险。
|
|
748
|
+
rejRecs.push({ taskId: tid, runId: attr ? attr.runId : '', model: attr ? attr.model : '', size: size, rDay: rDay, mDay: dayKeyOf(m.at), text: String(m.text || '') })
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return { stamp: stamp, runRecs: runRecs, taskRecs: taskRecs, rejRecs: rejRecs }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// 耗时分布:最近秩口径 p90 = sorted[ceil(0.9n)-1];中位数偶数 n 取两中值均值。空数组 → 两个 null。
|
|
755
|
+
function medP90(durs) {
|
|
756
|
+
if (!durs.length) return { med: null, p90: null }
|
|
757
|
+
durs.sort(function (a, b) { return a - b })
|
|
758
|
+
var n = durs.length
|
|
759
|
+
var med = (n % 2) ? durs[(n - 1) / 2] : (durs[n / 2 - 1] + durs[n / 2]) / 2
|
|
760
|
+
return { med: med, p90: durs[Math.min(n - 1, Math.ceil(n * 0.9) - 1)] }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// 装配层:归一化记录 → scoreboard。范围裁剪在这层现算(run 日落点 dayInRange 两端闭区间,
|
|
764
|
+
// 与 usageSummary 同一 range 口径);缓存命中与否不影响裁剪正确性(裁剪不依赖提取结果以外的状态)。
|
|
765
|
+
function assembleScoreboard(recs, range) {
|
|
766
|
+
var hasRange = rangeOn(range)
|
|
767
|
+
var bmap = {} // bucketKey(model|role|size) -> 聚合器
|
|
768
|
+
var bTasks = {} // bucketKey -> { taskId: true }(桶内任务级指标的分母来源)
|
|
769
|
+
var meta = { tasks: recs.taskRecs.length, runsSettled: recs.runRecs.length, bucketed: 0, roleOther: 0, noModel: 0, noSize: 0, rejUnattributed: 0 }
|
|
770
|
+
var tr = { firstPassByDay: {}, durationBuckets: { lt10m: 0, m10to30: 0, m30to60: 0, gt60m: 0 }, timeoutByDay: {}, rejectByDay: {}, resume: { runs: 0, completed: 0, rate: null } }
|
|
771
|
+
// ===== 任务级趋势:一次通过率 byDay(resolved 任务按落定日)+ 卡时长分布四桶 =====
|
|
772
|
+
var tById = {}
|
|
773
|
+
for (var ti = 0; ti < recs.taskRecs.length; ti++) {
|
|
774
|
+
var trc = recs.taskRecs[ti]
|
|
775
|
+
tById[trc.id] = trc
|
|
776
|
+
if (!trc.resolved) continue
|
|
777
|
+
if (hasRange && !dayInRange(trc.resolvedDay, range)) continue // resolvedDay ''(老数据)范围下剔除:宁可漏不错
|
|
778
|
+
if (trc.resolvedDay) {
|
|
779
|
+
var fc = tr.firstPassByDay[trc.resolvedDay] || { resolved: 0, firstPass: 0 }
|
|
780
|
+
fc.resolved++
|
|
781
|
+
if (!trc.rejectCount) fc.firstPass++
|
|
782
|
+
tr.firstPassByDay[trc.resolvedDay] = fc
|
|
783
|
+
}
|
|
784
|
+
if (trc.cardDurMs !== null) {
|
|
785
|
+
// <10m / 10-30m / 30-60m(含 60m 端点,与规模段「含端归中」同哲学)/ >60m
|
|
786
|
+
if (trc.cardDurMs < 600000) tr.durationBuckets.lt10m++
|
|
787
|
+
else if (trc.cardDurMs < 1800000) tr.durationBuckets.m10to30++
|
|
788
|
+
else if (trc.cardDurMs <= 3600000) tr.durationBuckets.m30to60++
|
|
789
|
+
else tr.durationBuckets.gt60m++
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// ===== run 级:趋势(全角色全模型,「全部模型」汇总只看日落点)+ 桶(worker/verifier × 有模型 × 规模可知)=====
|
|
793
|
+
for (var qi = 0; qi < recs.runRecs.length; qi++) {
|
|
794
|
+
var q = recs.runRecs[qi]
|
|
795
|
+
var inR = !hasRange || dayInRange(q.day, range) // day ''(无 at 老 run)范围下剔除
|
|
796
|
+
if (inR) {
|
|
797
|
+
// 超时率走势 byDay:落定值实测是 'timeout/error'(dispatch.settleRun 失败臂 + 幽灵回收),
|
|
798
|
+
// 字面 'timeout' 兼容预留。日落点取 run.at(与范围裁剪同一口径)。
|
|
799
|
+
if (q.day) {
|
|
800
|
+
var tc = tr.timeoutByDay[q.day] || { runs: 0, timeout: 0 }
|
|
801
|
+
tc.runs++
|
|
802
|
+
if (q.outcome === 'timeout/error' || q.outcome === 'timeout') tc.timeout++
|
|
803
|
+
tr.timeoutByDay[q.day] = tc
|
|
804
|
+
}
|
|
805
|
+
// 续跑成功率:resume 稀疏旗标(仅续跑记录置 true)的 run 里,最终 completed 的占比
|
|
806
|
+
if (q.resume) { tr.resume.runs++; if (q.outcome === 'completed') tr.resume.completed++ }
|
|
807
|
+
} else {
|
|
808
|
+
// 范围外 run 不进趋势也不进桶(meta 计数保持范围口径:裁剪后还剩多少进了桶)
|
|
809
|
+
if (hasRange) continue
|
|
810
|
+
}
|
|
811
|
+
if (q.role === 'other') { meta.roleOther++; continue } // hook/缺省角色不进 worker/verifier 对比桶(卡1口径)
|
|
812
|
+
if (!q.model) { meta.noModel++; continue } // 老 run 缺模型名:如实计数,不硬塞「(未知)」桶
|
|
813
|
+
if (!q.size) { meta.noSize++; continue } // 任务未结算出有效消耗:规模不可知,不进桶
|
|
814
|
+
var key = q.model + '|' + q.role + '|' + q.size
|
|
815
|
+
var b = bmap[key]
|
|
816
|
+
if (!b) {
|
|
817
|
+
b = { key: key, model: q.model, role: q.role, size: q.size, runs: 0, effSum: 0, effCount: 0, durs: [], rejected: 0, resolved: 0, firstPass: 0, timeouts: 0, resumes: 0, inpSum: 0, crSum: 0 }
|
|
818
|
+
bmap[key] = b; bTasks[key] = {}
|
|
819
|
+
}
|
|
820
|
+
b.runs++
|
|
821
|
+
meta.bucketed++
|
|
822
|
+
if (q.eff !== null) { b.effSum += q.eff; b.effCount++ }
|
|
823
|
+
if (q.durMs !== null) b.durs.push(q.durMs)
|
|
824
|
+
if (q.outcome === 'timeout/error' || q.outcome === 'timeout') b.timeouts++
|
|
825
|
+
if (q.resume) b.resumes++
|
|
826
|
+
if (q.inp !== null || q.cr !== null) { b.inpSum += q.inp || 0; b.crSum += q.cr || 0 }
|
|
827
|
+
bTasks[key][q.taskId] = true
|
|
828
|
+
}
|
|
829
|
+
// ===== 驳回率:归因到桶(卡1 attributeRejection 已在提取层跑完,这里只按桶累加)=====
|
|
830
|
+
for (var ji = 0; ji < recs.rejRecs.length; ji++) {
|
|
831
|
+
var j = recs.rejRecs[ji]
|
|
832
|
+
// 无 run 可归 / 归因 run 缺模型 / 任务规模不可知 → 如实计 rejUnattributed,不塞进任何桶
|
|
833
|
+
if (!j.runId || !j.model || !j.size) { meta.rejUnattributed++; continue }
|
|
834
|
+
// 与被驳回 run 的桶归属同一裁剪口径:run 被范围裁掉,其驳回也随之裁掉(聚合体不自相矛盾)
|
|
835
|
+
if (hasRange && !dayInRange(j.rDay, range)) continue
|
|
836
|
+
var jb = bmap[j.model + '|worker|' + j.size]
|
|
837
|
+
if (jb) jb.rejected++
|
|
838
|
+
else meta.rejUnattributed++ // 归因 run 自身没进桶的边缘(记录被裁/缺字段):不凭空造桶
|
|
839
|
+
}
|
|
840
|
+
// ===== 一次通过率(任务级指标):桶内 resolved 任务中 rejectCount=0 的占比 =====
|
|
841
|
+
// 任务按「它有哪些 run 落在这个桶」归属(一张卡多个模型的 run 会各进各桶);范围下按 resolvedDay 裁剪
|
|
842
|
+
//(落定日不在范围内 → 不计入分子分母)。
|
|
843
|
+
for (var bk in bmap) {
|
|
844
|
+
if (!Object.prototype.hasOwnProperty.call(bmap, bk)) continue
|
|
845
|
+
var bb = bmap[bk]
|
|
846
|
+
var tset = bTasks[bk]
|
|
847
|
+
for (var tId in tset) {
|
|
848
|
+
if (!Object.prototype.hasOwnProperty.call(tset, tId)) continue
|
|
849
|
+
var tk = tById[tId]
|
|
850
|
+
if (!tk || !tk.resolved) continue
|
|
851
|
+
if (hasRange && !dayInRange(tk.resolvedDay, range)) continue
|
|
852
|
+
bb.resolved++
|
|
853
|
+
if (!tk.rejectCount) bb.firstPass++
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// ===== 七指标定稿 =====
|
|
857
|
+
var out = []
|
|
858
|
+
for (var fk in bmap) {
|
|
859
|
+
if (!Object.prototype.hasOwnProperty.call(bmap, fk)) continue
|
|
860
|
+
var f = bmap[fk]
|
|
861
|
+
var mp = medP90(f.durs)
|
|
862
|
+
out.push({
|
|
863
|
+
model: f.model, role: f.role, size: f.size, runs: f.runs,
|
|
864
|
+
// ① 有效 token:均值 + 总额(有效口径 = 输入+输出+缓存写,与 effectiveTokens 单点同源)
|
|
865
|
+
effSum: f.effSum, effAvg: f.effCount ? Math.round(f.effSum / f.effCount) : null,
|
|
866
|
+
// ② 耗时:endedAt - at,中位数 + P90(ms);无合法时刻的 run 不进分布
|
|
867
|
+
durMedianMs: mp.med, durP90Ms: mp.p90,
|
|
868
|
+
// ③ 驳回率 = 归因到本桶 run 的驳回次数 / 桶内 runs
|
|
869
|
+
rejected: f.rejected, rejectRate: f.runs ? f.rejected / f.runs : 0,
|
|
870
|
+
// ④ 一次通过率 = 桶内 resolved 且 rejectCount=0 / 桶内 resolved(无 resolved 任务 → null,不假装 0)
|
|
871
|
+
resolved: f.resolved, firstPass: f.firstPass, firstPassRate: f.resolved ? f.firstPass / f.resolved : null,
|
|
872
|
+
// ⑤ 超时率 = outcome 超时落定 / 桶内 runs
|
|
873
|
+
timeouts: f.timeouts, timeoutRate: f.runs ? f.timeouts / f.runs : 0,
|
|
874
|
+
// ⑥ 续跑率 = resume:true 的 run / 桶内 runs
|
|
875
|
+
resumes: f.resumes, resumeRate: f.runs ? f.resumes / f.runs : 0,
|
|
876
|
+
// ⑦ 缓存命中率 = cacheRead / max(input+cacheRead, 1)。
|
|
877
|
+
// ⚠️ provider 口径差异钉:本看板数据源(v4 日志 usage)实测 input 与 cacheRead 分列(kimi/anthropic
|
|
878
|
+
// 口径);若某 provider 把缓存读并进 input,分母被双计、命中率失真——跨 provider 对比先核各家口径。
|
|
879
|
+
// 无 usage 样本(inpSum+crSum=0)→ null(不假装 0)。
|
|
880
|
+
cacheHitRate: (f.inpSum + f.crSum) > 0 ? f.crSum / (f.inpSum + f.crSum) : null,
|
|
881
|
+
// 样本量护栏:runs < 5 标 insufficient(客户端据此灰显不排名)
|
|
882
|
+
insufficient: f.runs < SCOREBOARD_MIN_SAMPLE
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
// 稳定排序:有效总额降序(主对比维度),并列按 key 字典序(轮询间输出不抖动)
|
|
886
|
+
out.sort(function (a, b2) {
|
|
887
|
+
var dd = b2.effSum - a.effSum
|
|
888
|
+
if (dd !== 0) return dd
|
|
889
|
+
return a.model + '|' + a.role + '|' + a.size < b2.model + '|' + b2.role + '|' + b2.size ? -1 : 1
|
|
890
|
+
})
|
|
891
|
+
tr.resume.rate = tr.resume.runs ? tr.resume.completed / tr.resume.runs : null
|
|
892
|
+
// ===== 驳回聚类(卡5①,task-muxhu1zv):规则法 Top3 + 约定建议文本 =====
|
|
893
|
+
// 裁剪口径:按驳回消息**自身**日落点 mDay(与驳回率桶的归因 run 日落点 rDay 相互独立)——
|
|
894
|
+
// direct 档 / 无 worker run 可归的驳回同样是学习飞轮样本,不能因归因失败从聚类里消失;
|
|
895
|
+
// mDay=''(无 at 老数据)在范围下剔除(宁可漏不错,与全局 range 哲学一致)。
|
|
896
|
+
var rejTexts = []
|
|
897
|
+
for (var ci = 0; ci < recs.rejRecs.length; ci++) {
|
|
898
|
+
var rcj = recs.rejRecs[ci]
|
|
899
|
+
if (hasRange && !dayInRange(rcj.mDay, range)) continue
|
|
900
|
+
// 驳回率 byDay(质量异动告警③ 的数据源,异常驱动审视②):按驳回消息自身本地日落点逐日计数——
|
|
901
|
+
// 与 rejectionClusters 同一 mDay 口径(direct 档 / 无 worker run 可归的驳回同样是样本,不因归因失败消失)。
|
|
902
|
+
if (rcj.mDay) { var rbd = tr.rejectByDay[rcj.mDay] || (tr.rejectByDay[rcj.mDay] = { rejects: 0 }); rbd.rejects++ }
|
|
903
|
+
rejTexts.push(rcj.text)
|
|
904
|
+
}
|
|
905
|
+
return { buckets: out, trends: tr, meta: meta, rejectionClusters: clusterRejections(rejTexts) }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// 记分卡聚合入口(get-tasks 内组装到 usageSummary.scoreboard)。
|
|
909
|
+
// cacheStore 缺省/非对象 → 不缓存每次全量提取(测试/老宿主兜底,与 readMainWindowUsage 同约定)。
|
|
910
|
+
// 命中(taskCount 与 stamp 双双相同)→ 提取层零重算,只做装配层范围裁剪现算。
|
|
911
|
+
export function buildScoreboard(tasks, range, cacheStore) {
|
|
912
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
913
|
+
var store = (cacheStore && typeof cacheStore === 'object') ? cacheStore : null
|
|
914
|
+
var recs = null
|
|
915
|
+
if (store && store.recs) {
|
|
916
|
+
var pb = scoreProbe(list)
|
|
917
|
+
if (store.taskCount === pb.count && store.stamp === pb.stamp) recs = store.recs
|
|
918
|
+
}
|
|
919
|
+
if (!recs) {
|
|
920
|
+
recs = buildScoreRecs(list)
|
|
921
|
+
if (store) { store.taskCount = list.length; store.stamp = recs.stamp; store.recs = recs }
|
|
922
|
+
}
|
|
923
|
+
return assembleScoreboard(recs, range)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ===== 驳回聚类 Top3(卡5①,task-muxhu1zv):规则法,不上 embedding =====
|
|
927
|
+
// 驳回文本结构高度模板化(rejectionText 产物:「验收驳回 · <summary>\n\n核对项:\n<checks>」),
|
|
928
|
+
// 规则表按主窗口验收驳回的真实高频原因归纳(本机看板驳回库为空时的种子表,随真实样本增补):
|
|
929
|
+
// 每条驳回只归入**第一个命中**的类目(规则表顺序 = 优先级),占比分母 = 驳回总数(Σpct ≤ 100%);
|
|
930
|
+
// 全部未命中进 'other' 兜底桶(如实计数参与排名,建议文案只能给通用模板——人工归纳后再补规则)。
|
|
931
|
+
// 纯函数零 IO;texts 为驳回原文数组(装配层已按范围裁剪),空数组 → total:0 top:[],绝不抛错。
|
|
932
|
+
export var REJECTION_CLUSTER_RULES = [
|
|
933
|
+
{ id: 'acceptance-skipped', label: '验收脚本未实跑', re: /验收脚本|未实跑|未跑|没跑|真实输出|逐字粘贴|自测没过|自测不/, advice: '在派发约定/Worker prompt 补一条:上报前必须逐字粘贴验收脚本真实输出(未通过不得上报完成)' },
|
|
934
|
+
{ id: 'readme-stale', label: 'README 未同步', re: /README|文档未|文档不|未同步/, advice: '在派发约定补一条:行为/口径/界面变化必须同步 README 双份并逐字一致(npm run sync-readme)' },
|
|
935
|
+
{ id: 'scope-creep', label: '范围越界', re: /范围越界|越界|范围外|改了别|额外改动|顺带改|顺手改/, advice: '在派发约定补一条:只改任务 touches 声明范围内的文件;范围外发现的问题另建卡,不顺带改' },
|
|
936
|
+
{ id: 'assertion-missing', label: '断言缺失', re: /断言/, advice: '在派发约定补一条:任务断言必须逐条配单测锁定——断言无测试视为未完成' },
|
|
937
|
+
{ id: 'artifact-stale', label: '产物未重组装', re: /重组装|重新组装|产物未|build-client|组装产物|未重建|未重新构建/, advice: '在派发约定补一条:改完组装源(lib/client/* 等)必须重组装产物(npm run build-client)再上报' },
|
|
938
|
+
]
|
|
939
|
+
var REJ_CLUSTER_OTHER = { id: 'other', label: '其他(未匹配规则)', advice: '人工归纳该类驳回的共性后,在派发约定/Worker prompt 补一条对应规则' }
|
|
940
|
+
var REJ_SAMPLE_MAX = 3 // 每类代表原文最多保留条数
|
|
941
|
+
var REJ_SAMPLE_CLIP = 80 // 代表原文截断长度(客户端放 title 悬浮)
|
|
942
|
+
|
|
943
|
+
// texts → { total, top: [{ id, label, count, pct, samples, suggestion }], otherCount, restCount }
|
|
944
|
+
// top 最多 3 条(count 降序,并列按规则表顺序、other 恒排最后);
|
|
945
|
+
// pct = Math.round(count/total*100)(整数百分比);samples = 该类前 3 条原文各截 80 字;
|
|
946
|
+
// suggestion = 「驳回 TopN『label』占 pct%——建议<类目 advice>」(结构化约定建议文本,卡5②)。
|
|
947
|
+
export function clusterRejections(texts) {
|
|
948
|
+
var out = { total: 0, top: [], otherCount: 0, restCount: 0 }
|
|
949
|
+
var list = Array.isArray(texts) ? texts : []
|
|
950
|
+
var acc = [] // 与规则表同序的累加器 + 末尾 other
|
|
951
|
+
var ri
|
|
952
|
+
for (ri = 0; ri <= REJECTION_CLUSTER_RULES.length; ri++) {
|
|
953
|
+
var rule = ri < REJECTION_CLUSTER_RULES.length ? REJECTION_CLUSTER_RULES[ri] : REJ_CLUSTER_OTHER
|
|
954
|
+
acc.push({ id: rule.id, label: rule.label, advice: rule.advice, count: 0, samples: [] })
|
|
955
|
+
}
|
|
956
|
+
for (var i = 0; i < list.length; i++) {
|
|
957
|
+
var text = String(list[i] == null ? '' : list[i])
|
|
958
|
+
if (!text.trim()) continue // 空文本不算一条驳回样本(防御脏数据,不稀释占比)
|
|
959
|
+
out.total++
|
|
960
|
+
var hit = acc.length - 1 // 缺省 other
|
|
961
|
+
for (ri = 0; ri < REJECTION_CLUSTER_RULES.length; ri++) {
|
|
962
|
+
if (REJECTION_CLUSTER_RULES[ri].re.test(text)) { hit = ri; break } // 首命中即归类(顺序=优先级)
|
|
963
|
+
}
|
|
964
|
+
var a = acc[hit]
|
|
965
|
+
a.count++
|
|
966
|
+
if (a.samples.length < REJ_SAMPLE_MAX) a.samples.push(text.slice(0, REJ_SAMPLE_CLIP))
|
|
967
|
+
}
|
|
968
|
+
// count>0 的类目参与排名:count 降序,并列按 acc 下标(规则表顺序,other 天然最后)
|
|
969
|
+
var ranked = acc.filter(function (x) { return x.count > 0 }).sort(function (x, y) {
|
|
970
|
+
if (y.count !== x.count) return y.count - x.count
|
|
971
|
+
return acc.indexOf(x) - acc.indexOf(y)
|
|
972
|
+
})
|
|
973
|
+
for (var k = 0; k < ranked.length; k++) {
|
|
974
|
+
var r = ranked[k]
|
|
975
|
+
if (r.id === 'other') out.otherCount = r.count
|
|
976
|
+
if (k < 3) {
|
|
977
|
+
var pct = Math.round(r.count / out.total * 100)
|
|
978
|
+
out.top.push({
|
|
979
|
+
id: r.id, label: r.label, count: r.count, pct: pct, samples: r.samples,
|
|
980
|
+
suggestion: '驳回 Top' + (k + 1) + '『' + r.label + '』占 ' + pct + '%——建议' + r.advice,
|
|
981
|
+
})
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
out.restCount = ranked.length > 3 ? ranked.length - 3 : 0 // Top3 之外还有几类(客户端 caption 提示用)
|
|
985
|
+
return out
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ===== 按表现荐模型 hint(卡5③,task-muxhu1zv):healthHints 通道的规则 =====
|
|
989
|
+
// 判定(全部满足才亮,任一不达标静默返回 null——无足够数据不亮):
|
|
990
|
+
// 桶范围:role='worker' 且规模段 ∈ {small, medium}(中小卡桶;verifier 与大卡不参与——荐的是默认
|
|
991
|
+
// workerModel,大卡样本少且成本结构不同质)。同模型的中小桶合并成模型级样本再判。
|
|
992
|
+
// ① 样本量:模型合并 runs >= MODEL_HINT_MIN_RUNS(10)——恰好 10 合格,9 不亮;
|
|
993
|
+
// ② 一次通过率:合并 firstPass/resolved >= MODEL_HINT_MIN_PASS(0.9)——恰好 90% 合格,89% 不亮;
|
|
994
|
+
// resolved=0(桶内无落定任务)→ 通过率不可知,不判(不假装 0%);
|
|
995
|
+
// ③ 有效均值显著更低:候选 = 合格模型中 effAvg(ΣeffSum/ΣeffCount)最低者,且对**其他每个**合格
|
|
996
|
+
// 模型的 effAvg 都严格低 >MODEL_HINT_EFF_GAP(30%,取最小差距判定——最保守口径;恰好 30% 不亮);
|
|
997
|
+
// 合格模型 <2 个 → 无对比对象,「显著低于其他」无从谈起 → null。
|
|
998
|
+
// 返回 healthHints 条目形态 { level: 'warn', text }(黄条),由 rpc get-tasks 拼进 d.healthHints。
|
|
999
|
+
export var MODEL_HINT_MIN_RUNS = 10
|
|
1000
|
+
export var MODEL_HINT_MIN_PASS = 0.9
|
|
1001
|
+
export var MODEL_HINT_EFF_GAP = 0.3
|
|
1002
|
+
|
|
1003
|
+
// token 数量简写(hint 文案用):>=1M → X.XM,>=1K → X.XK,否则取整原数
|
|
1004
|
+
function fmtTokShort(n) {
|
|
1005
|
+
if (!isFinite(n) || n < 0) return '0'
|
|
1006
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
|
1007
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
|
1008
|
+
return String(Math.round(n))
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function modelPerfHint(sb) {
|
|
1012
|
+
if (!sb || !Array.isArray(sb.buckets)) return null
|
|
1013
|
+
// 按模型合并 worker × 中小卡桶(单桶样本常不足 10,合并后模型级样本才是判定基准)
|
|
1014
|
+
var mm = {}
|
|
1015
|
+
for (var i = 0; i < sb.buckets.length; i++) {
|
|
1016
|
+
var b = sb.buckets[i]
|
|
1017
|
+
if (!b || b.role !== 'worker') continue
|
|
1018
|
+
if (b.size !== 'small' && b.size !== 'medium') continue
|
|
1019
|
+
var a = mm[b.model] || (mm[b.model] = { model: b.model, runs: 0, resolved: 0, firstPass: 0, effSum: 0, effCount: 0 })
|
|
1020
|
+
a.runs += numOr0(b.runs)
|
|
1021
|
+
a.resolved += numOr0(b.resolved)
|
|
1022
|
+
a.firstPass += numOr0(b.firstPass)
|
|
1023
|
+
a.effSum += numOr0(b.effSum)
|
|
1024
|
+
a.effCount += numOr0(b.effCount)
|
|
1025
|
+
}
|
|
1026
|
+
var cands = []
|
|
1027
|
+
for (var mk in mm) {
|
|
1028
|
+
if (!Object.prototype.hasOwnProperty.call(mm, mk)) continue
|
|
1029
|
+
var v = mm[mk]
|
|
1030
|
+
if (v.runs < MODEL_HINT_MIN_RUNS) continue // ① 样本量
|
|
1031
|
+
if (!v.resolved) continue // ② 通过率不可知(无落定任务)不判
|
|
1032
|
+
var pass = v.firstPass / v.resolved
|
|
1033
|
+
if (pass < MODEL_HINT_MIN_PASS) continue // ② 通过率 <90%(恰好 90% 合格)
|
|
1034
|
+
if (!v.effCount) continue // ③ 有效均值不可知(无 usage 留账)不判
|
|
1035
|
+
cands.push({ model: v.model, runs: v.runs, pass: pass, effAvg: v.effSum / v.effCount })
|
|
1036
|
+
}
|
|
1037
|
+
if (cands.length < 2) return null // 无对比对象(单模型独秀不构成「显著低于其他」)
|
|
1038
|
+
cands.sort(function (x, y) { return (x.effAvg - y.effAvg) || (x.model < y.model ? -1 : 1) }) // 有效均值升序(并列按模型名,确定性)
|
|
1039
|
+
var best = cands[0]
|
|
1040
|
+
// 对其他每个合格模型都严格低 >30%:最小差距 > 30% 才成立(恰好 30% 不亮)
|
|
1041
|
+
var minGap = Infinity
|
|
1042
|
+
for (var gi = 1; gi < cands.length; gi++) {
|
|
1043
|
+
var oAvg = cands[gi].effAvg
|
|
1044
|
+
var gap = oAvg > 0 ? (oAvg - best.effAvg) / oAvg : 0 // 对方均值为 0:无法定义「低 30%」,按 0 处理(必不亮)
|
|
1045
|
+
if (gap < minGap) minGap = gap
|
|
1046
|
+
}
|
|
1047
|
+
if (!(minGap > MODEL_HINT_EFF_GAP)) return null
|
|
1048
|
+
return {
|
|
1049
|
+
level: 'warn',
|
|
1050
|
+
text: '模型 ' + best.model + ' 在中小卡表现最优(通过率 ' + Math.round(best.pass * 100) + '% · 有效均值 ' + fmtTokShort(best.effAvg) +
|
|
1051
|
+
',较其他合格模型低 ' + Math.round(minGap * 100) + '%),建议设为默认 workerModel',
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// ===== 质量异动告警(异常驱动审视②,proposal n-muxyyvgonpoo):scoreboard.trends 数据驱动的环比告警 =====
|
|
1056
|
+
// 三条同构规则(阈值常量置顶):近 7 天 vs 再前 7 天,两侧样本各 ≥ QUALITY_MIN_SAMPLE 才判(防小样本误报)。
|
|
1057
|
+
// ① 一次通过率(firstPassByDay:firstPass/resolved)**跌** > QUALITY_CHANGE_PP 个百分点 → 建议抽查近期验收;
|
|
1058
|
+
// ② 超时率(timeoutByDay:timeout/runs)**涨** > QUALITY_CHANGE_PP 个百分点 → 建议排查派发/超时链路;
|
|
1059
|
+
// ③ 驳回率(rejectByDay:rejects / timeoutByDay 同日的 runs)**涨** > QUALITY_CHANGE_PP 个百分点 → 建议审查驳回原因。
|
|
1060
|
+
// 数据全部来自 scoreboard.trends(卡2 已建,本卡只做判定 + hint,不重建数据)。
|
|
1061
|
+
// 纯函数零 IO:日期只解析已有字符串做窗口切分;now 缺省 Date.now()(测试可注入钉死窗口,与卡2 零时钟纪律一致——
|
|
1062
|
+
// 环比的「近 7 天」必须相对某个「今天」,这是本函数的唯一时钟依赖,故显式参数化)。
|
|
1063
|
+
export var QUALITY_CHANGE_PP = 10 // 环比变化阈值(百分点):恰好 10pp 不亮、严格 >10pp 才亮
|
|
1064
|
+
export var QUALITY_MIN_SAMPLE = 5 // 两侧样本护栏:任一侧样本 <5 不判(防小样本噪声)
|
|
1065
|
+
export var QUALITY_WINDOW_DAYS = 7 // 环比窗口:近 7 天 vs 再前 7 天
|
|
1066
|
+
|
|
1067
|
+
// 近 count 天的本地日 key(旧→新,最后一个是今天):与 localDayKey 同一本地 getters 口径
|
|
1068
|
+
function qualityRecentDays(nowMs, count) {
|
|
1069
|
+
var out = []
|
|
1070
|
+
var d = new Date(nowMs)
|
|
1071
|
+
for (var i = count - 1; i >= 0; i--) out.push(localDayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - i)))
|
|
1072
|
+
return out
|
|
1073
|
+
}
|
|
1074
|
+
// 把 byDay 在指定日 key 集合上的指定数值字段累加;byDay 缺失/脏 → 全 0(绝不抛错)
|
|
1075
|
+
function qualitySumCells(byDay, keys, fields) {
|
|
1076
|
+
var out = {}
|
|
1077
|
+
for (var f = 0; f < fields.length; f++) out[fields[f]] = 0
|
|
1078
|
+
if (!byDay || typeof byDay !== 'object') return out
|
|
1079
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1080
|
+
var c = byDay[keys[i]]
|
|
1081
|
+
if (!c || typeof c !== 'object') continue
|
|
1082
|
+
for (var f2 = 0; f2 < fields.length; f2++) out[fields[f2]] += numOr0(c[fields[f2]])
|
|
1083
|
+
}
|
|
1084
|
+
return out
|
|
1085
|
+
}
|
|
1086
|
+
// 环比百分点差(a - b,四舍五入到 0.1pp):恰好 10pp 不亮 / 10.1pp 亮——0.1pp 精度 + 严格 > 阈值,浮点安全
|
|
1087
|
+
function qualityPpDelta(a, b) { return Math.round((a - b) * 1000) / 10 }
|
|
1088
|
+
|
|
1089
|
+
export function qualityChangeHints(sb, now) {
|
|
1090
|
+
var tr = sb && sb.trends && typeof sb.trends === 'object' ? sb.trends : null
|
|
1091
|
+
if (!tr) return []
|
|
1092
|
+
var hints = []
|
|
1093
|
+
var nowMs = (typeof now === 'number' && isFinite(now)) ? now : Date.now()
|
|
1094
|
+
// 近 14 天窗口切两半:近 7 天(含今天)vs 再前 7 天
|
|
1095
|
+
var days = qualityRecentDays(nowMs, QUALITY_WINDOW_DAYS * 2)
|
|
1096
|
+
var near = days.slice(-QUALITY_WINDOW_DAYS)
|
|
1097
|
+
var prior = days.slice(0, QUALITY_WINDOW_DAYS)
|
|
1098
|
+
// --- ① 一次通过率跌 ---
|
|
1099
|
+
var fpN = qualitySumCells(tr.firstPassByDay, near, ['resolved', 'firstPass'])
|
|
1100
|
+
var fpP = qualitySumCells(tr.firstPassByDay, prior, ['resolved', 'firstPass'])
|
|
1101
|
+
if (fpN.resolved >= QUALITY_MIN_SAMPLE && fpP.resolved >= QUALITY_MIN_SAMPLE) {
|
|
1102
|
+
var fpNR = fpN.firstPass / fpN.resolved, fpPR = fpP.firstPass / fpP.resolved
|
|
1103
|
+
if (qualityPpDelta(fpPR, fpNR) > QUALITY_CHANGE_PP) {
|
|
1104
|
+
hints.push({ level: 'warn', text: '质量异动:一次通过率本周 ' + Math.round(fpNR * 100) + '%(上周 ' + Math.round(fpPR * 100) + '%),建议抽查近期验收' })
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// --- ② 超时率涨(分母 = 当日落定 runs)---
|
|
1108
|
+
var toN = qualitySumCells(tr.timeoutByDay, near, ['runs', 'timeout'])
|
|
1109
|
+
var toP = qualitySumCells(tr.timeoutByDay, prior, ['runs', 'timeout'])
|
|
1110
|
+
if (toN.runs >= QUALITY_MIN_SAMPLE && toP.runs >= QUALITY_MIN_SAMPLE) {
|
|
1111
|
+
var toNR = toN.timeout / toN.runs, toPR = toP.timeout / toP.runs
|
|
1112
|
+
if (qualityPpDelta(toNR, toPR) > QUALITY_CHANGE_PP) {
|
|
1113
|
+
hints.push({ level: 'warn', text: '质量异动:超时率本周 ' + Math.round(toNR * 100) + '%(上周 ' + Math.round(toPR * 100) + '%),建议排查派发/超时链路' })
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// --- ③ 驳回率涨(分子 = rejectByDay 的 rejects,分母 = timeoutByDay 同日的 runs)---
|
|
1117
|
+
var rjN = qualitySumCells(tr.rejectByDay, near, ['rejects'])
|
|
1118
|
+
var rjP = qualitySumCells(tr.rejectByDay, prior, ['rejects'])
|
|
1119
|
+
if (toN.runs >= QUALITY_MIN_SAMPLE && toP.runs >= QUALITY_MIN_SAMPLE) {
|
|
1120
|
+
var rjNR = rjN.rejects / toN.runs, rjPR = rjP.rejects / toP.runs
|
|
1121
|
+
if (qualityPpDelta(rjNR, rjPR) > QUALITY_CHANGE_PP) {
|
|
1122
|
+
hints.push({ level: 'warn', text: '质量异动:驳回率本周 ' + Math.round(rjNR * 100) + '%(上周 ' + Math.round(rjPR * 100) + '%),建议审查近期驳回原因' })
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return hints
|
|
1126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agent-board",
|
|
3
3
|
"description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build-client": "node scripts/build-client.cjs",
|
|
9
|
-
"test": "node --test test
|
|
9
|
+
"test": "node --test test/**/*.test.mjs",
|
|
10
10
|
"pretest": "npm run build-client",
|
|
11
11
|
"sync-readme": "node -e \"require('fs').copyFileSync('../../README.md','README.md')\"",
|
|
12
12
|
"prepublishOnly": "npm run build-client && node --check index.mjs && node --check lib/client.js && node --check lib/core.mjs && npm test",
|