dsh-agent-board 1.7.3 → 1.7.5

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/lib/usage.mjs CHANGED
@@ -186,7 +186,8 @@ function dayCell(v) {
186
186
  // 为什么在 host 侧过滤:run 级数据(runs[i].usage 五分量 + runs[i].at 时间戳)只在 host,
187
187
  // 客户端轮询只拿聚合——范围筛选只能在这一层做,客户端没法自己裁。
188
188
  // 口径:run 的本地日落点 localDayKey(run.at) 判 in/out;范围外 run 不计入 total/byModel/top,
189
- // byDay 也同步裁到范围内(范围外日不入 byDay)。
189
+ // byDay 也同步裁到范围内(范围外日不入 byDay;run 留账任务按入选 run 逐日重建,to 端闭区间)。
190
+ // byDayFull 与范围无关恒为全量(task-muwsnyqv ②)——客户端「今日」「近 7 天」固定口径读它。
190
191
  // 缺省(无 range / from 与 to 皆空)= 现状逐字不变(parity):不引入任何新的过滤分支语义,
191
192
  // 老任务(无 runs 留账)与无 at 的老 run 照旧全量计入。
192
193
  function normDayKey(v) {
@@ -231,12 +232,37 @@ function runUsageOf(r) {
231
232
  // 「按任务 updatedAt 单日近似」,于是切范围后 topTasks 与无范围逐字相同(用户活体实证:选 2026-10-05
232
233
  // 后 total/byDay 收窄、Top8 第一名仍是全量 48M 的那张卡)。现在改为逐 run 精确裁切。
233
234
  export function aggregateUsageSummary(tasks, range) {
234
- var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byModelEff: {}, byDay: {}, topTasks: [] }
235
+ var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byModelEff: {}, byDay: {}, byDayFull: {}, topTasks: [] }
235
236
  var list = Array.isArray(tasks) ? tasks : []
236
237
  var hasRange = rangeOn(range)
237
238
  for (var i = 0; i < list.length; i++) {
238
239
  var t = list[i]; var uRaw = t && t.usage
239
240
  if (!uRaw) continue
241
+ // ===== byDayFull:未过滤全量日账(task-muwsnyqv ②)=====
242
+ // 「今日」「近 7 天」是固定口径(客户端 caption 承诺不随统计范围变),数据源必须是全量日账——
243
+ // 只读范围内裁剪的 byDay 时,「今天被范围裁掉」会让今日大数字凭空归零(活体实证:范围
244
+ // {10-05,10-06} 下今日条目整丢)。口径与无范围 byDay 逐字同源:任务级 uRaw.byDay 逐日合并
245
+ // (老 number → e=null 不猜),无 byDay 的存量任务整笔归 updatedAt 本地日;与下方范围判定
246
+ // 完全解耦(范围外任务照样计入 byDayFull),无范围时 byDayFull 与 byDay 内容一致(parity)。
247
+ var byF = (uRaw.byDay && typeof uRaw.byDay === 'object') ? uRaw.byDay : null
248
+ var hasByF = false
249
+ for (var bk0 in byF) { if (Object.prototype.hasOwnProperty.call(byF, bk0)) { hasByF = true; break } }
250
+ if (hasByF) {
251
+ for (var dk0 in byF) {
252
+ if (!Object.prototype.hasOwnProperty.call(byF, dk0)) continue
253
+ var c0 = dayCell(byF[dk0])
254
+ // 合并规则与 byDay 相同:任一来源 e 不可知 → 该日 e=null(不假装算得出);全部已知才累加。
255
+ var cur0 = s.byDayFull[dk0] || { t: 0, e: undefined }
256
+ cur0.t += c0.t
257
+ if (c0.e === null) cur0.e = null
258
+ else if (cur0.e !== null) cur0.e = (cur0.e || 0) + c0.e
259
+ if (cur0.e === undefined) cur0.e = null
260
+ s.byDayFull[dk0] = cur0
261
+ }
262
+ } else if (numOr0(uRaw.total)) {
263
+ var lkF = dayKeyOf(uRaw.updatedAt)
264
+ if (lkF) { var lcF = s.byDayFull[lkF] || { t: 0, e: undefined }; lcF.t += numOr0(uRaw.total); lcF.e = null; s.byDayFull[lkF] = lcF }
265
+ }
240
266
  // 过滤层只决定「这次任务贡献哪些用量」,下游累加口径(模型/Top/byDay)逐字不动。
241
267
  var u = uRaw
242
268
  if (hasRange) {
@@ -248,6 +274,7 @@ export function aggregateUsageSummary(tasks, range) {
248
274
  var covered = 0 // 落了 usage 的 run 数(判「有没有 run 级留账可裁」——与 at 无关)
249
275
  var modeledTot = 0 // 入选 run 里**记了模型名**的那部分总量(剩下的按任务级占比摊,见下)
250
276
  var modeledEff = 0
277
+ var dayB = null // 入选 run 的逐日归桶(重建范围内 byDay,见下)
251
278
  for (var ri = 0; ri < trs.length; ri++) {
252
279
  var r = trs[ri]
253
280
  var ru = runUsageOf(r)
@@ -257,6 +284,17 @@ export function aggregateUsageSummary(tasks, range) {
257
284
  picked++
258
285
  uSum.total += ru.total; uSum.input += ru.input; uSum.output += ru.output
259
286
  uSum.cacheRead += ru.cacheRead; uSum.cacheWrite += ru.cacheWrite
287
+ // byDay 同步重建(task-muwsnyqv ①):旧版合成的 u 丢 byDay/updatedAt → 范围内日账整块消失
288
+ // (「今天被吞」实证:range 含今天时 total/effective 含今天、byDay 却无今天)。按入选 run 的
289
+ // at 本地日逐日归桶——与范围过滤同一落点口径(dayInRange 两端闭区间:d > to 才丢),
290
+ // from=to=今天 时今天必在 byDay。run 级五分量俱全 → e 恒为已知有效值(不是 null)。
291
+ var rd = dayKeyOf(r && r.at)
292
+ if (rd) {
293
+ if (!dayB) dayB = {}
294
+ var dc1 = dayB[rd] || { t: 0, e: 0 }
295
+ dc1.t += ru.total; dc1.e += effectiveTokens(ru)
296
+ dayB[rd] = dc1
297
+ }
260
298
  // 按模型分布同步按范围收窄:模型 key 与结算同源(派发覆盖 > 日志记录),run 留账里没记模型名
261
299
  // → 退化为任务级模型小计,但只在本次有 run 入选时摊(否则等于把范围外消耗算进来)。
262
300
  // 合计与有效(byModel / byModelEff)同一轮同源累加,两条口径不各算一遍。
@@ -289,7 +327,7 @@ export function aggregateUsageSummary(tasks, range) {
289
327
  modelsE[tmKeys[ti]] = (modelsE[tmKeys[ti]] || 0) + Math.round(numOr0(taskModels[tmKeys[ti]]) * ratioE)
290
328
  }
291
329
  }
292
- u = { total: uSum.total, input: uSum.input, output: uSum.output, cacheRead: uSum.cacheRead, cacheWrite: uSum.cacheWrite, models: models || {}, modelEff: modelsE || {}, runs: picked }
330
+ u = { total: uSum.total, input: uSum.input, output: uSum.output, cacheRead: uSum.cacheRead, cacheWrite: uSum.cacheWrite, models: models || {}, modelEff: modelsE || {}, runs: picked, byDay: dayB || {} }
293
331
  } else if (!covered) {
294
332
  // 无 run 级留账可裁(本功能上线前的存量卡;或 runs 条目全都没落 usage):没有 run 级时间戳,
295
333
  // 整笔退化为「updatedAt 的本地日」这一近似口径(与 byDay 存量兜底同源);无 updatedAt / 解析失败 →
@@ -353,3 +391,660 @@ export function aggregateUsageSummary(tasks, range) {
353
391
  s.topTasks = s.topTasks.slice(0, 8)
354
392
  return s
355
393
  }
394
+
395
+ // ===== 主窗口(本会话对话)Token 消耗:增量尾读主会话自己的 v4 日志(task-muwsol23)=====
396
+ // 与 readRunUsage 的差异:主会话日志持续增长(可达数万事件),3s 轮询下每轮全量读会把
397
+ // 同步 IO 摊进轮询热路径——这里按「文件偏移水位」增量尾读,缓存条目由调用方持有(键=会话 id):
398
+ // { size, mtimeMs, total, input, output, cacheRead, cacheWrite, effective, byDay, hasUsage }
399
+ // - size = 已结算到的文件偏移;只推进到「最后一个完整解压帧」的末尾——尾部半帧(写入中)
400
+ // 不结算,下轮从该偏移重读(绝不丢事件、绝不重复计账);
401
+ // - 文件变大 → 只读 [size, 新size) 的新增字节段(3s 轮询稳态零全量重读);
402
+ // - 文件变小(日志轮换/截断)→ 缓存作废,全量重读一次(封顶 64MB,与 readRunUsage 同一护栏);
403
+ // - 文件不变 → 零读直接用缓存聚合值。
404
+ // 聚合口径与 Worker 完全同源:五分量(input/output/cacheRead/cacheWrite/total)+ e=input+output+cacheWrite,
405
+ // 日落点取 assistant/message 事件的 time 字段(epoch ms)的本地日(与 run 口径同一 localDayKey)。
406
+ // 范围裁剪:缓存里存的是**未裁剪**全量聚合;range({from,to} 本地日)在出参时按 byDay 逐日裁
407
+ // (与 run 口径同一 dayInRange,两端闭区间),范围内 total/effective/cacheRead 由入选日重算——
408
+ // 无日可判(无 time/坏 time)的事件只进平账不进任何日,范围下自然被裁(宁可漏不错)。
409
+ // 隔离红线:返回值只挂 usageSummary.mainWindow——不进 Top8 / 「本看板累计」/ 架构健康与学习飞轮基数。
410
+ // 失败静默降级:日志不存在/不可读/解不出任何 usage → null(客户端不渲染该行),绝不抛进轮询。
411
+ var MAINWIN_LOG_CAP = 64 * 1024 * 1024
412
+ // 聚合器初始形态(也是缓存条目形态)
413
+ function mainWinAcc() {
414
+ return { size: 0, mtimeMs: 0, total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byDay: {}, hasUsage: false }
415
+ }
416
+ // 事件时间戳(v4 事件的 time 字段,epoch ms)→ 本地日 key;缺失/非法返回 ''(不进任何日账)
417
+ function dayKeyOfMs(ms) {
418
+ var t = Number(ms)
419
+ if (!isFinite(t) || t <= 0) return ''
420
+ var d = new Date(t)
421
+ if (isNaN(d.getTime())) return ''
422
+ return localDayKey(d)
423
+ }
424
+ // 把 buf 内全部完整帧的 assistant/message usage 累加进 acc;返回「结算到」的相对偏移。
425
+ // 帧序旧→新扫(追加写):中间坏帧跳过但照样结算掉(坏帧永远读不回来,别让水位卡住);
426
+ // 最后一段解压失败 = 尾部半帧(写入进行中)→ 停在它的起点,下轮写完整后重读。
427
+ function mainWinAggregateFrames(acc, buf) {
428
+ var offs = []
429
+ var at = buf.indexOf(ZSTD_MAGIC)
430
+ while (at >= 0) { offs.push(at); at = buf.indexOf(ZSTD_MAGIC, at + 1) }
431
+ var consumed = 0
432
+ for (var k = 0; k < offs.length; k++) {
433
+ var end = k + 1 < offs.length ? offs[k + 1] : buf.length
434
+ var lines = null
435
+ try {
436
+ lines = zstdDecompressSync(buf.subarray(offs[k], end)).toString('utf8').split('\n')
437
+ } catch (_) {
438
+ if (k === offs.length - 1) break // 尾部半帧:不结算,下轮从它的起点重读
439
+ consumed = end; continue
440
+ }
441
+ for (var i = 0; i < lines.length; i++) {
442
+ if (!lines[i]) continue
443
+ var e
444
+ try { e = JSON.parse(lines[i]) } catch (_) { continue }
445
+ if (!e || e.type !== 'assistant/message') continue
446
+ var u = e.data && e.data.usage
447
+ if (!u) continue
448
+ var inp = numOr0(u.inputTokens), outp = numOr0(u.outputTokens)
449
+ var cr = numOr0(u.cacheReadTokens), cw = numOr0(u.cacheWriteTokens)
450
+ var tot = numOr0(u.totalTokens) || (inp + outp + cr + cw)
451
+ var eff = inp + outp + cw
452
+ acc.total += tot; acc.input += inp; acc.output += outp
453
+ acc.cacheRead += cr; acc.cacheWrite += cw; acc.effective += eff
454
+ acc.hasUsage = true
455
+ var dk = dayKeyOfMs(e.time)
456
+ if (dk) {
457
+ var cell = acc.byDay[dk] || { t: 0, e: 0, cr: 0 }
458
+ cell.t += tot; cell.e += eff; cell.cr += cr
459
+ acc.byDay[dk] = cell
460
+ }
461
+ }
462
+ consumed = end
463
+ }
464
+ return consumed
465
+ }
466
+ // 主窗口消耗读取入口。cacheStore 缺省(undefined/非对象)→ 不缓存每次全量读(测试/老宿主兜底)。
467
+ // 出参 byDay 单元为 { t, e, cr }(t=总量含缓存读,e=有效,cr=缓存读)——范围裁剪要按日重算
468
+ // 三个口径,故比 run 日账 {t,e} 多带一个缓存读分量;出参是副本,改出参不污染缓存。
469
+ export function readMainWindowUsage(sessionId, sessionsRoot, range, cacheStore) {
470
+ try {
471
+ if (!sessionId) return null
472
+ var log = findRunLog(sessionId, sessionsRoot)
473
+ if (!log) return null
474
+ var st = null
475
+ try { st = fsNode.statSync(log) } catch (_) { return null }
476
+ var store = (cacheStore && typeof cacheStore === 'object') ? cacheStore : null
477
+ var key = String(sessionId)
478
+ var acc = store ? store[key] : null
479
+ if (acc && st.size < acc.size) acc = null // 日志轮换/截断(文件变小)→ 缓存作废全量重读
480
+ if (!acc) {
481
+ var buf0 = readLogBytes(log, MAINWIN_LOG_CAP)
482
+ if (!buf0) return null
483
+ var base0 = st.size - buf0.length // 封顶截断时只丢最老帧(buf 起点对应绝对偏移 base0)
484
+ acc = mainWinAcc()
485
+ acc.size = base0 + mainWinAggregateFrames(acc, buf0)
486
+ acc.mtimeMs = st.mtimeMs
487
+ if (store) store[key] = acc
488
+ } else if (st.size > acc.size) {
489
+ // 增量尾读:只读新增字节段 [acc.size, st.size);读取失败保留旧聚合值出数(降级不闪断,下轮再试)
490
+ var buf1 = readLogBytes(log, st.size - acc.size)
491
+ if (buf1) { acc.size += mainWinAggregateFrames(acc, buf1); acc.mtimeMs = st.mtimeMs }
492
+ }
493
+ // st.size === acc.size → 零读直接用缓存
494
+ if (!acc.hasUsage) return null // 还没有任何 usage 事件 → 静默(客户端不渲染该行)
495
+ var out = { total: 0, effective: 0, cacheRead: 0, byDay: {} }
496
+ if (rangeOn(range)) {
497
+ for (var dk in acc.byDay) {
498
+ if (!Object.prototype.hasOwnProperty.call(acc.byDay, dk)) continue
499
+ if (!dayInRange(dk, range)) continue
500
+ var c = acc.byDay[dk]
501
+ out.byDay[dk] = { t: c.t, e: c.e, cr: c.cr }
502
+ out.total += c.t; out.effective += c.e; out.cacheRead += c.cr
503
+ }
504
+ } else {
505
+ out.total = acc.total; out.effective = acc.effective; out.cacheRead = acc.cacheRead
506
+ for (var dk2 in acc.byDay) {
507
+ if (!Object.prototype.hasOwnProperty.call(acc.byDay, dk2)) continue
508
+ var c2 = acc.byDay[dk2]
509
+ out.byDay[dk2] = { t: c2.t, e: c2.e, cr: c2.cr }
510
+ }
511
+ }
512
+ return out
513
+ } catch (e) {
514
+ // 展示统计绝不影响轮询:任何意外一律记日志降级为「无该行」
515
+ console.error('[task-board] readMainWindowUsage failed (' + sessionId + '):', String(e))
516
+ return null
517
+ }
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: {}, 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
+ rejTexts.push(rcj.text)
901
+ }
902
+ return { buckets: out, trends: tr, meta: meta, rejectionClusters: clusterRejections(rejTexts) }
903
+ }
904
+
905
+ // 记分卡聚合入口(get-tasks 内组装到 usageSummary.scoreboard)。
906
+ // cacheStore 缺省/非对象 → 不缓存每次全量提取(测试/老宿主兜底,与 readMainWindowUsage 同约定)。
907
+ // 命中(taskCount 与 stamp 双双相同)→ 提取层零重算,只做装配层范围裁剪现算。
908
+ export function buildScoreboard(tasks, range, cacheStore) {
909
+ var list = Array.isArray(tasks) ? tasks : []
910
+ var store = (cacheStore && typeof cacheStore === 'object') ? cacheStore : null
911
+ var recs = null
912
+ if (store && store.recs) {
913
+ var pb = scoreProbe(list)
914
+ if (store.taskCount === pb.count && store.stamp === pb.stamp) recs = store.recs
915
+ }
916
+ if (!recs) {
917
+ recs = buildScoreRecs(list)
918
+ if (store) { store.taskCount = list.length; store.stamp = recs.stamp; store.recs = recs }
919
+ }
920
+ return assembleScoreboard(recs, range)
921
+ }
922
+
923
+ // ===== 驳回聚类 Top3(卡5①,task-muxhu1zv):规则法,不上 embedding =====
924
+ // 驳回文本结构高度模板化(rejectionText 产物:「验收驳回 · <summary>\n\n核对项:\n<checks>」),
925
+ // 规则表按主窗口验收驳回的真实高频原因归纳(本机看板驳回库为空时的种子表,随真实样本增补):
926
+ // 每条驳回只归入**第一个命中**的类目(规则表顺序 = 优先级),占比分母 = 驳回总数(Σpct ≤ 100%);
927
+ // 全部未命中进 'other' 兜底桶(如实计数参与排名,建议文案只能给通用模板——人工归纳后再补规则)。
928
+ // 纯函数零 IO;texts 为驳回原文数组(装配层已按范围裁剪),空数组 → total:0 top:[],绝不抛错。
929
+ export var REJECTION_CLUSTER_RULES = [
930
+ { id: 'acceptance-skipped', label: '验收脚本未实跑', re: /验收脚本|未实跑|未跑|没跑|真实输出|逐字粘贴|自测没过|自测不/, advice: '在派发约定/Worker prompt 补一条:上报前必须逐字粘贴验收脚本真实输出(未通过不得上报完成)' },
931
+ { id: 'readme-stale', label: 'README 未同步', re: /README|文档未|文档不|未同步/, advice: '在派发约定补一条:行为/口径/界面变化必须同步 README 双份并逐字一致(npm run sync-readme)' },
932
+ { id: 'scope-creep', label: '范围越界', re: /范围越界|越界|范围外|改了别|额外改动|顺带改|顺手改/, advice: '在派发约定补一条:只改任务 touches 声明范围内的文件;范围外发现的问题另建卡,不顺带改' },
933
+ { id: 'assertion-missing', label: '断言缺失', re: /断言/, advice: '在派发约定补一条:任务断言必须逐条配单测锁定——断言无测试视为未完成' },
934
+ { id: 'artifact-stale', label: '产物未重组装', re: /重组装|重新组装|产物未|build-client|组装产物|未重建|未重新构建/, advice: '在派发约定补一条:改完组装源(lib/client/* 等)必须重组装产物(npm run build-client)再上报' },
935
+ ]
936
+ var REJ_CLUSTER_OTHER = { id: 'other', label: '其他(未匹配规则)', advice: '人工归纳该类驳回的共性后,在派发约定/Worker prompt 补一条对应规则' }
937
+ var REJ_SAMPLE_MAX = 3 // 每类代表原文最多保留条数
938
+ var REJ_SAMPLE_CLIP = 80 // 代表原文截断长度(客户端放 title 悬浮)
939
+
940
+ // texts → { total, top: [{ id, label, count, pct, samples, suggestion }], otherCount, restCount }
941
+ // top 最多 3 条(count 降序,并列按规则表顺序、other 恒排最后);
942
+ // pct = Math.round(count/total*100)(整数百分比);samples = 该类前 3 条原文各截 80 字;
943
+ // suggestion = 「驳回 TopN『label』占 pct%——建议<类目 advice>」(结构化约定建议文本,卡5②)。
944
+ export function clusterRejections(texts) {
945
+ var out = { total: 0, top: [], otherCount: 0, restCount: 0 }
946
+ var list = Array.isArray(texts) ? texts : []
947
+ var acc = [] // 与规则表同序的累加器 + 末尾 other
948
+ var ri
949
+ for (ri = 0; ri <= REJECTION_CLUSTER_RULES.length; ri++) {
950
+ var rule = ri < REJECTION_CLUSTER_RULES.length ? REJECTION_CLUSTER_RULES[ri] : REJ_CLUSTER_OTHER
951
+ acc.push({ id: rule.id, label: rule.label, advice: rule.advice, count: 0, samples: [] })
952
+ }
953
+ for (var i = 0; i < list.length; i++) {
954
+ var text = String(list[i] == null ? '' : list[i])
955
+ if (!text.trim()) continue // 空文本不算一条驳回样本(防御脏数据,不稀释占比)
956
+ out.total++
957
+ var hit = acc.length - 1 // 缺省 other
958
+ for (ri = 0; ri < REJECTION_CLUSTER_RULES.length; ri++) {
959
+ if (REJECTION_CLUSTER_RULES[ri].re.test(text)) { hit = ri; break } // 首命中即归类(顺序=优先级)
960
+ }
961
+ var a = acc[hit]
962
+ a.count++
963
+ if (a.samples.length < REJ_SAMPLE_MAX) a.samples.push(text.slice(0, REJ_SAMPLE_CLIP))
964
+ }
965
+ // count>0 的类目参与排名:count 降序,并列按 acc 下标(规则表顺序,other 天然最后)
966
+ var ranked = acc.filter(function (x) { return x.count > 0 }).sort(function (x, y) {
967
+ if (y.count !== x.count) return y.count - x.count
968
+ return acc.indexOf(x) - acc.indexOf(y)
969
+ })
970
+ for (var k = 0; k < ranked.length; k++) {
971
+ var r = ranked[k]
972
+ if (r.id === 'other') out.otherCount = r.count
973
+ if (k < 3) {
974
+ var pct = Math.round(r.count / out.total * 100)
975
+ out.top.push({
976
+ id: r.id, label: r.label, count: r.count, pct: pct, samples: r.samples,
977
+ suggestion: '驳回 Top' + (k + 1) + '『' + r.label + '』占 ' + pct + '%——建议' + r.advice,
978
+ })
979
+ }
980
+ }
981
+ out.restCount = ranked.length > 3 ? ranked.length - 3 : 0 // Top3 之外还有几类(客户端 caption 提示用)
982
+ return out
983
+ }
984
+
985
+ // ===== 按表现荐模型 hint(卡5③,task-muxhu1zv):healthHints 通道的规则 =====
986
+ // 判定(全部满足才亮,任一不达标静默返回 null——无足够数据不亮):
987
+ // 桶范围:role='worker' 且规模段 ∈ {small, medium}(中小卡桶;verifier 与大卡不参与——荐的是默认
988
+ // workerModel,大卡样本少且成本结构不同质)。同模型的中小桶合并成模型级样本再判。
989
+ // ① 样本量:模型合并 runs >= MODEL_HINT_MIN_RUNS(10)——恰好 10 合格,9 不亮;
990
+ // ② 一次通过率:合并 firstPass/resolved >= MODEL_HINT_MIN_PASS(0.9)——恰好 90% 合格,89% 不亮;
991
+ // resolved=0(桶内无落定任务)→ 通过率不可知,不判(不假装 0%);
992
+ // ③ 有效均值显著更低:候选 = 合格模型中 effAvg(ΣeffSum/ΣeffCount)最低者,且对**其他每个**合格
993
+ // 模型的 effAvg 都严格低 >MODEL_HINT_EFF_GAP(30%,取最小差距判定——最保守口径;恰好 30% 不亮);
994
+ // 合格模型 <2 个 → 无对比对象,「显著低于其他」无从谈起 → null。
995
+ // 返回 healthHints 条目形态 { level: 'warn', text }(黄条),由 rpc get-tasks 拼进 d.healthHints。
996
+ export var MODEL_HINT_MIN_RUNS = 10
997
+ export var MODEL_HINT_MIN_PASS = 0.9
998
+ export var MODEL_HINT_EFF_GAP = 0.3
999
+
1000
+ // token 数量简写(hint 文案用):>=1M → X.XM,>=1K → X.XK,否则取整原数
1001
+ function fmtTokShort(n) {
1002
+ if (!isFinite(n) || n < 0) return '0'
1003
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
1004
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
1005
+ return String(Math.round(n))
1006
+ }
1007
+
1008
+ export function modelPerfHint(sb) {
1009
+ if (!sb || !Array.isArray(sb.buckets)) return null
1010
+ // 按模型合并 worker × 中小卡桶(单桶样本常不足 10,合并后模型级样本才是判定基准)
1011
+ var mm = {}
1012
+ for (var i = 0; i < sb.buckets.length; i++) {
1013
+ var b = sb.buckets[i]
1014
+ if (!b || b.role !== 'worker') continue
1015
+ if (b.size !== 'small' && b.size !== 'medium') continue
1016
+ var a = mm[b.model] || (mm[b.model] = { model: b.model, runs: 0, resolved: 0, firstPass: 0, effSum: 0, effCount: 0 })
1017
+ a.runs += numOr0(b.runs)
1018
+ a.resolved += numOr0(b.resolved)
1019
+ a.firstPass += numOr0(b.firstPass)
1020
+ a.effSum += numOr0(b.effSum)
1021
+ a.effCount += numOr0(b.effCount)
1022
+ }
1023
+ var cands = []
1024
+ for (var mk in mm) {
1025
+ if (!Object.prototype.hasOwnProperty.call(mm, mk)) continue
1026
+ var v = mm[mk]
1027
+ if (v.runs < MODEL_HINT_MIN_RUNS) continue // ① 样本量
1028
+ if (!v.resolved) continue // ② 通过率不可知(无落定任务)不判
1029
+ var pass = v.firstPass / v.resolved
1030
+ if (pass < MODEL_HINT_MIN_PASS) continue // ② 通过率 <90%(恰好 90% 合格)
1031
+ if (!v.effCount) continue // ③ 有效均值不可知(无 usage 留账)不判
1032
+ cands.push({ model: v.model, runs: v.runs, pass: pass, effAvg: v.effSum / v.effCount })
1033
+ }
1034
+ if (cands.length < 2) return null // 无对比对象(单模型独秀不构成「显著低于其他」)
1035
+ cands.sort(function (x, y) { return (x.effAvg - y.effAvg) || (x.model < y.model ? -1 : 1) }) // 有效均值升序(并列按模型名,确定性)
1036
+ var best = cands[0]
1037
+ // 对其他每个合格模型都严格低 >30%:最小差距 > 30% 才成立(恰好 30% 不亮)
1038
+ var minGap = Infinity
1039
+ for (var gi = 1; gi < cands.length; gi++) {
1040
+ var oAvg = cands[gi].effAvg
1041
+ var gap = oAvg > 0 ? (oAvg - best.effAvg) / oAvg : 0 // 对方均值为 0:无法定义「低 30%」,按 0 处理(必不亮)
1042
+ if (gap < minGap) minGap = gap
1043
+ }
1044
+ if (!(minGap > MODEL_HINT_EFF_GAP)) return null
1045
+ return {
1046
+ level: 'warn',
1047
+ text: '模型 ' + best.model + ' 在中小卡表现最优(通过率 ' + Math.round(best.pass * 100) + '% · 有效均值 ' + fmtTokShort(best.effAvg) +
1048
+ ',较其他合格模型低 ' + Math.round(minGap * 100) + '%),建议设为默认 workerModel',
1049
+ }
1050
+ }