oh-my-knowledge 0.43.0 → 0.44.0

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.
@@ -0,0 +1,16 @@
1
+ import type { ArtifactGraphDocument, EvaluationReport } from '../types/index.js';
2
+ export interface BuildEvalGraphOptions {
3
+ report: EvaluationReport;
4
+ sourcePath: string;
5
+ generatedAt?: string;
6
+ }
7
+ export interface PersistEvalGraphOptions extends BuildEvalGraphOptions {
8
+ outputDir: string;
9
+ fileStem?: string;
10
+ }
11
+ export interface PersistEvalGraphResult {
12
+ graphPath: string;
13
+ }
14
+ export declare function evalGraphDirForReportOutput(reportOutputDir: string): string;
15
+ export declare function buildEvalArtifactGraph(options: BuildEvalGraphOptions): ArtifactGraphDocument;
16
+ export declare function persistEvalGraphSidecar(options: PersistEvalGraphOptions): PersistEvalGraphResult;
@@ -0,0 +1,351 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { graphFileName } from '../eval-core/artifact-file-names.js';
5
+ function shortHash(input) {
6
+ return createHash('sha256').update(input).digest('hex').slice(0, 12);
7
+ }
8
+ function jsonPointerToken(value) {
9
+ return value.replaceAll('~', '~0').replaceAll('/', '~1');
10
+ }
11
+ function sampleSetHash(sampleHashes) {
12
+ if (!sampleHashes || Object.keys(sampleHashes).length === 0)
13
+ return undefined;
14
+ const canonical = Object.entries(sampleHashes)
15
+ .sort(([a], [b]) => a.localeCompare(b))
16
+ .map(([id, hash]) => `${id}:${hash}`)
17
+ .join('|');
18
+ return shortHash(canonical);
19
+ }
20
+ function statusFromScore(score) {
21
+ // Display band only. This is not the verdict gate and intentionally does not
22
+ // reference DEFAULT_GATE_THRESHOLD or statistical significance decisions.
23
+ if (score === undefined || !Number.isFinite(score))
24
+ return 'unknown';
25
+ if (score >= 4)
26
+ return 'ok';
27
+ if (score >= 3)
28
+ return 'warning';
29
+ return 'failed';
30
+ }
31
+ function assertionStatus(result) {
32
+ // Assertion topology status only. Pure LLM-scored samples have no assertion
33
+ // pass/fail edge, so they stay unknown here even when compositeScore is high.
34
+ if (result.error || !result.ok)
35
+ return 'failed';
36
+ const details = result.assertions?.details;
37
+ if (!details || details.length === 0)
38
+ return 'unknown';
39
+ return details.every((detail) => detail.passed) ? 'ok' : 'failed';
40
+ }
41
+ function artifactBinding(variant, hash) {
42
+ if (hash && hash !== 'no-skill') {
43
+ return { bindingStrength: 'content-hash', keys: { artifactHash: hash } };
44
+ }
45
+ return { bindingStrength: 'name-only', keys: { variantName: variant } };
46
+ }
47
+ function sampleBinding(sampleId, hash) {
48
+ if (hash) {
49
+ return { bindingStrength: 'content-hash', keys: { sampleHash: hash } };
50
+ }
51
+ return { bindingStrength: 'name-only', keys: { sampleId } };
52
+ }
53
+ function variantConfigByName(report) {
54
+ return new Map((report.meta.variantConfigs ?? []).map((config) => [config.variant, config]));
55
+ }
56
+ function scopeArtifactKind(report) {
57
+ const kinds = new Set((report.meta.variantConfigs ?? [])
58
+ .map((config) => config.artifactKind)
59
+ .filter((kind) => kind !== 'baseline'));
60
+ return kinds.size === 1 ? [...kinds][0] : undefined;
61
+ }
62
+ function sampleEvidence(report, sampleId) {
63
+ return [{
64
+ sourceKind: 'sample',
65
+ sourceId: sampleId,
66
+ selector: { selectorKind: 'sample-id', value: sampleId },
67
+ contentHash: report.meta.sampleHashes?.[sampleId],
68
+ label: sampleId,
69
+ }];
70
+ }
71
+ function evalResultEvidence(report, resultIndex, variant) {
72
+ return [{
73
+ sourceKind: 'eval-report',
74
+ sourceId: report.id,
75
+ selector: {
76
+ selectorKind: 'json-pointer',
77
+ value: `/results/${resultIndex}/variants/${jsonPointerToken(variant)}`,
78
+ },
79
+ label: `${variant} result`,
80
+ }];
81
+ }
82
+ function assertionEvidence(report, sampleId, index, resultIndex, variant) {
83
+ if (report.sampleSnapshots?.[sampleId]?.assertions?.[index]) {
84
+ return [{
85
+ sourceKind: 'sample',
86
+ sourceId: sampleId,
87
+ selector: {
88
+ selectorKind: 'json-pointer',
89
+ value: `/sampleSnapshots/${jsonPointerToken(sampleId)}/assertions/${index}`,
90
+ },
91
+ contentHash: report.meta.sampleHashes?.[sampleId],
92
+ label: `assertion ${index + 1}`,
93
+ }];
94
+ }
95
+ if (resultIndex !== undefined && variant !== undefined) {
96
+ return [{
97
+ sourceKind: 'eval-report',
98
+ sourceId: report.id,
99
+ selector: {
100
+ selectorKind: 'json-pointer',
101
+ value: `/results/${resultIndex}/variants/${jsonPointerToken(variant)}/assertions/details/${index}`,
102
+ },
103
+ label: `${variant} assertion ${index + 1}`,
104
+ }];
105
+ }
106
+ return [{
107
+ sourceKind: 'sample',
108
+ sourceId: sampleId,
109
+ contentHash: report.meta.sampleHashes?.[sampleId],
110
+ label: `assertion ${index + 1}`,
111
+ }];
112
+ }
113
+ function sampleStableKey(report, sampleId) {
114
+ const hash = report.meta.sampleHashes?.[sampleId];
115
+ return hash ? `v1:sample:${hash}` : `v1:sample:${report.id}:${sampleId}`;
116
+ }
117
+ function assertionStableKey(report, sampleId, index) {
118
+ return `${sampleStableKey(report, sampleId)}:assertion:${index}`;
119
+ }
120
+ function sampleAttrs(snapshot) {
121
+ if (!snapshot)
122
+ return undefined;
123
+ const display = {};
124
+ if (snapshot.capability?.length)
125
+ display.capability = snapshot.capability;
126
+ if (snapshot.construct)
127
+ display.construct = snapshot.construct;
128
+ if (snapshot.difficulty)
129
+ display.difficulty = snapshot.difficulty;
130
+ if (snapshot.provenance)
131
+ display.provenance = snapshot.provenance;
132
+ if (snapshot.tripwire)
133
+ display.tripwire = true;
134
+ if (snapshot.assertions?.length)
135
+ display.assertionCount = snapshot.assertions.length;
136
+ return Object.keys(display).length > 0 ? { display } : undefined;
137
+ }
138
+ export function evalGraphDirForReportOutput(reportOutputDir) {
139
+ return basename(reportOutputDir) === 'reports'
140
+ ? join(dirname(reportOutputDir), 'graphs', 'eval')
141
+ : join(reportOutputDir, 'graphs', 'eval');
142
+ }
143
+ export function buildEvalArtifactGraph(options) {
144
+ const { report, sourcePath } = options;
145
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
146
+ const nodes = [];
147
+ const edges = [];
148
+ const nodeIdsByStableKey = new Map();
149
+ const configs = variantConfigByName(report);
150
+ const addNode = (stableKey, nodeKind, nodeRole, label, extra = {}) => {
151
+ const existing = nodeIdsByStableKey.get(stableKey);
152
+ if (existing)
153
+ return existing;
154
+ const id = `node:${shortHash(stableKey)}`;
155
+ nodeIdsByStableKey.set(stableKey, id);
156
+ nodes.push({
157
+ id,
158
+ stableKey,
159
+ nodeKind,
160
+ nodeRole,
161
+ layer: 'measurement',
162
+ label,
163
+ ...extra,
164
+ });
165
+ return id;
166
+ };
167
+ const addEdge = (fromNodeId, toNodeId, edgeKind, extra = {}) => {
168
+ const id = `edge:${shortHash(`${fromNodeId}|${edgeKind}|${toNodeId}|${edges.length}`)}`;
169
+ edges.push({
170
+ id,
171
+ fromNodeId,
172
+ toNodeId,
173
+ edgeKind,
174
+ layer: 'measurement',
175
+ ...extra,
176
+ });
177
+ };
178
+ const variantNodeIds = new Map();
179
+ for (const variant of report.meta.variants) {
180
+ const artifactHash = report.meta.artifactHashes?.[variant];
181
+ const config = configs.get(variant);
182
+ const variantNodeId = addNode(`v1:variant:${report.id}:${variant}`, 'variant', 'entity', variant, {
183
+ status: statusFromScore(report.summary?.[variant]?.avgCompositeScore),
184
+ binding: artifactBinding(variant, artifactHash),
185
+ metrics: {
186
+ ...(report.summary?.[variant]?.avgCompositeScore !== undefined
187
+ ? { avgCompositeScore: report.summary[variant].avgCompositeScore }
188
+ : {}),
189
+ ...(report.summary?.[variant]?.totalSamples !== undefined
190
+ ? { totalSamples: report.summary[variant].totalSamples }
191
+ : {}),
192
+ },
193
+ attrs: {
194
+ display: {
195
+ ...(config ? {
196
+ artifactKind: config.artifactKind,
197
+ artifactSource: config.artifactSource,
198
+ experimentRole: config.experimentRole,
199
+ executionStrategy: config.executionStrategy,
200
+ } : {}),
201
+ },
202
+ },
203
+ evidenceRefs: [{
204
+ sourceKind: 'eval-report',
205
+ sourceId: report.id,
206
+ selector: { selectorKind: 'json-pointer', value: `/summary/${jsonPointerToken(variant)}` },
207
+ label: `${variant} summary`,
208
+ }],
209
+ });
210
+ variantNodeIds.set(variant, variantNodeId);
211
+ if (config?.artifactKind === 'skill' && artifactHash && artifactHash !== 'no-skill') {
212
+ const skillNodeId = addNode(`v1:skill:${artifactHash}`, 'skill', 'entity', variant, {
213
+ binding: { bindingStrength: 'content-hash', keys: { artifactHash } },
214
+ attrs: {
215
+ display: {
216
+ variant,
217
+ sourceLocator: config.locator,
218
+ },
219
+ },
220
+ evidenceRefs: [{
221
+ sourceKind: 'eval-report',
222
+ sourceId: report.id,
223
+ selector: { selectorKind: 'json-pointer', value: `/meta/artifactHashes/${jsonPointerToken(variant)}` },
224
+ contentHash: artifactHash,
225
+ label: `${variant} artifact hash`,
226
+ }],
227
+ });
228
+ addEdge(variantNodeId, skillNodeId, 'derived_from');
229
+ }
230
+ }
231
+ for (const [sampleId, snapshot] of Object.entries(report.sampleSnapshots ?? {})) {
232
+ const sampleNodeId = addNode(sampleStableKey(report, sampleId), 'sample', 'entity', sampleId, {
233
+ binding: sampleBinding(sampleId, report.meta.sampleHashes?.[sampleId]),
234
+ attrs: sampleAttrs(snapshot),
235
+ evidenceRefs: sampleEvidence(report, sampleId),
236
+ });
237
+ snapshot.assertions?.forEach((assertion, index) => {
238
+ const assertionNodeId = addNode(assertionStableKey(report, sampleId, index), 'assertion', 'entity', `assertion: ${assertion.type}`, {
239
+ attrs: { display: { type: assertion.type, weight: assertion.weight ?? 1 } },
240
+ evidenceRefs: assertionEvidence(report, sampleId, index),
241
+ });
242
+ addEdge(sampleNodeId, assertionNodeId, 'contains');
243
+ });
244
+ }
245
+ for (const [resultIndex, result] of report.results.entries()) {
246
+ const sampleNodeId = addNode(sampleStableKey(report, result.sample_id), 'sample', 'entity', result.sample_id, {
247
+ binding: sampleBinding(result.sample_id, report.meta.sampleHashes?.[result.sample_id]),
248
+ attrs: sampleAttrs(report.sampleSnapshots?.[result.sample_id]),
249
+ evidenceRefs: sampleEvidence(report, result.sample_id),
250
+ });
251
+ for (const [variant, variantResult] of Object.entries(result.variants)) {
252
+ const variantNodeId = variantNodeIds.get(variant);
253
+ if (!variantNodeId)
254
+ continue;
255
+ addEdge(variantNodeId, sampleNodeId, 'evaluates', {
256
+ status: assertionStatus(variantResult),
257
+ evidenceRefs: evalResultEvidence(report, resultIndex, variant),
258
+ });
259
+ const evalResultNodeId = addNode(`v1:eval-result:${report.id}:${variant}:${result.sample_id}`, 'eval_result', 'observation', `${variant} / ${result.sample_id}`, {
260
+ status: assertionStatus(variantResult),
261
+ metrics: {
262
+ durationMs: variantResult.durationMs,
263
+ costUSD: variantResult.costUSD,
264
+ ...(variantResult.compositeScore !== undefined ? { compositeScore: variantResult.compositeScore } : {}),
265
+ ...(variantResult.llmScore !== undefined ? { llmScore: variantResult.llmScore } : {}),
266
+ ...(variantResult.assertions ? { assertionScore: variantResult.assertions.score } : {}),
267
+ },
268
+ attrs: {
269
+ display: {
270
+ ok: variantResult.ok,
271
+ ...(variantResult.error ? { error: variantResult.error } : {}),
272
+ },
273
+ },
274
+ evidenceRefs: evalResultEvidence(report, resultIndex, variant),
275
+ });
276
+ addEdge(evalResultNodeId, variantNodeId, 'derived_from');
277
+ addEdge(evalResultNodeId, sampleNodeId, 'evaluates');
278
+ variantResult.assertions?.details.forEach((detail, index) => {
279
+ const assertionNodeId = addNode(assertionStableKey(report, result.sample_id, index), 'assertion', 'entity', `assertion: ${detail.type}`, {
280
+ attrs: { display: { type: detail.type, weight: detail.weight } },
281
+ evidenceRefs: assertionEvidence(report, result.sample_id, index, resultIndex, variant),
282
+ });
283
+ addEdge(evalResultNodeId, assertionNodeId, detail.passed ? 'passes' : 'fails', {
284
+ status: detail.passed ? 'ok' : 'failed',
285
+ evidenceRefs: evalResultEvidence(report, resultIndex, variant),
286
+ });
287
+ });
288
+ for (const [dimension, dimensionResult] of Object.entries(variantResult.dimensions ?? {})) {
289
+ const dimensionNodeId = addNode(`v1:judge-dimension:${report.id}:${variant}:${result.sample_id}:${dimension}`, 'judge_dimension', 'observation', dimension, {
290
+ status: statusFromScore(dimensionResult.score),
291
+ metrics: { score: dimensionResult.score },
292
+ attrs: { display: { reason: dimensionResult.reason } },
293
+ evidenceRefs: evalResultEvidence(report, resultIndex, variant),
294
+ });
295
+ addEdge(dimensionNodeId, evalResultNodeId, 'derived_from');
296
+ }
297
+ if (variantResult.diagnostic) {
298
+ const diagnosticNodeId = addNode(`v1:diagnostic:${report.id}:${variant}:${result.sample_id}`, 'diagnostic', 'observation', `diagnostic: ${variant} / ${result.sample_id}`, {
299
+ status: variantResult.diagnostic.ok ? 'warning' : 'failed',
300
+ attrs: {
301
+ display: {
302
+ rootCause: variantResult.diagnostic.rootCause,
303
+ failureModes: variantResult.diagnostic.failureModes ?? [],
304
+ },
305
+ },
306
+ evidenceRefs: evalResultEvidence(report, resultIndex, variant),
307
+ });
308
+ addEdge(diagnosticNodeId, evalResultNodeId, 'diagnoses', {
309
+ status: variantResult.diagnostic.ok ? 'warning' : 'failed',
310
+ });
311
+ }
312
+ }
313
+ }
314
+ return {
315
+ documentKind: 'artifact-graph',
316
+ schemaVersion: 1,
317
+ graphId: `eval:${report.id}`,
318
+ generatedAt,
319
+ source: {
320
+ sourceKind: 'eval',
321
+ sourceId: report.id,
322
+ sourcePath,
323
+ cliVersion: report.meta.cliVersion,
324
+ },
325
+ scope: {
326
+ cwd: process.cwd(),
327
+ artifactKind: scopeArtifactKind(report),
328
+ sourceLocator: report.meta.request?.samplesPath,
329
+ sampleSetHash: sampleSetHash(report.meta.sampleHashes),
330
+ },
331
+ nodes,
332
+ edges,
333
+ summaries: [{
334
+ summaryKind: 'coverage',
335
+ title: 'Eval measurement graph',
336
+ severity: report.results.some((result) => Object.values(result.variants).some((variant) => assertionStatus(variant) === 'failed'))
337
+ ? 'medium'
338
+ : 'info',
339
+ }],
340
+ };
341
+ }
342
+ export function persistEvalGraphSidecar(options) {
343
+ const graphDir = evalGraphDirForReportOutput(options.outputDir);
344
+ if (!existsSync(graphDir))
345
+ mkdirSync(graphDir, { recursive: true });
346
+ const fileStem = options.fileStem ?? options.report.id;
347
+ const graphPath = join(graphDir, graphFileName(fileStem));
348
+ const graph = buildEvalArtifactGraph(options);
349
+ writeFileSync(graphPath, JSON.stringify(graph, null, 2));
350
+ return { graphPath };
351
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { existsSync, statSync } from 'node:fs';
11
11
  import { basename, dirname, join, resolve } from 'node:path';
12
+ import { runFileSuffix } from '../eval-core/artifact-file-names.js';
12
13
  import { discoverVariants, resolveArtifacts } from '../inputs/skill-loader.js';
13
14
  import { DOCTOR_REPORT_SCHEMA_VERSION, isComposerRule } from '../types/doctor.js';
14
15
  import { getRegisteredRules } from './rules.js';
@@ -163,14 +164,8 @@ function inferSkillPath(artifact, baseDir) {
163
164
  // ---------------------------------------------------------------------------
164
165
  // Public entry
165
166
  // ---------------------------------------------------------------------------
166
- let reportIdCounter = 0;
167
167
  function nextReportId() {
168
- reportIdCounter += 1;
169
- const ts = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15);
170
- // 进程内计数器只防同进程同秒撞;跨进程同秒(两个 omk doctor 并发 / 不同项目)仍会撞同 id,
171
- // 经机器级卡片 dedup 当唯一键用时会静默并掉一份。加 4 位随机根治跨进程撞名(id 是标签非测量数)。
172
- const rand = Math.random().toString(36).slice(2, 6);
173
- return `doctor-${ts}-${reportIdCounter}-${rand}`;
168
+ return `doctor-${runFileSuffix()}`;
174
169
  }
175
170
  function readCliVersion() {
176
171
  // 不依赖 package.json import(避免 type 解析复杂度);用环境变量或退回 'unknown'
@@ -10,6 +10,6 @@ export declare function graphFileName(stem: string): string;
10
10
  export declare function cardFileName(stem: string): string;
11
11
  export declare function runTimestamp(date?: Date): string;
12
12
  export declare function randomRunToken(): string;
13
- export declare function runFileSuffix(counter?: number): string;
13
+ export declare function runFileSuffix(): string;
14
14
  export declare function stripDomainPrefix(id: string, domain: string): string;
15
15
  export declare function doctorReportFileStem(skillName: string, reportId: string): string;
@@ -32,9 +32,8 @@ export function runTimestamp(date = new Date()) {
32
32
  export function randomRunToken() {
33
33
  return Math.random().toString(36).slice(2, 6);
34
34
  }
35
- export function runFileSuffix(counter) {
36
- const middle = counter === undefined ? '' : `-${counter}`;
37
- return `${runTimestamp()}${middle}-${randomRunToken()}`;
35
+ export function runFileSuffix() {
36
+ return `${runTimestamp()}-${randomRunToken()}`;
38
37
  }
39
38
  export function stripDomainPrefix(id, domain) {
40
39
  const safeId = safeArtifactFileStem(id);
@@ -33,7 +33,7 @@ export interface PersistableReport {
33
33
  }
34
34
  export declare function persistReport(report: PersistableReport, outputDir: string | null): string | null;
35
35
  /**
36
- * run id 的时间戳后缀 `YYYYMMDD-HHmmss-rand4`。
36
+ * run id 的时间戳后缀 `YYYYMMDDTHHmmss-rand4`。
37
37
  * 含秒 + 4 位随机:id 是 run 标签(非测量数),但被 studio 机器级 dedup 与 managed 证据 (reportId,
38
38
  * contentHash) 去重当唯一键用。分钟级会让跨项目 / 同分钟重跑撞同 id → 索引静默顶掉一份、managed 错并一条。
39
39
  * 秒+随机根治撞名,保证每次 run 全局唯一。供 generateRunId 与 evolve 合并 id 共用,避免靠 split 反解格式。
@@ -5,7 +5,8 @@ import { createHash } from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DEFAULT_REPORTS_DIR } from './default-dirs.js';
7
7
  import { indexReportWrite } from './artifact-index.js';
8
- import { reportFilePath } from './artifact-file-names.js';
8
+ import { randomRunToken, reportFilePath, runTimestamp } from './artifact-file-names.js';
9
+ import { persistEvalGraphSidecar } from '../artifact-graph/eval.js';
9
10
  import { buildVariantSummary } from './schema.js';
10
11
  import { buildVariantConfig, resolveExecutionStrategy } from './execution-strategy.js';
11
12
  import { getJudgePromptHash } from '../grading/judge.js';
@@ -276,6 +277,20 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
276
277
  }])),
277
278
  };
278
279
  }
280
+ function isEvaluationReport(report) {
281
+ return report['kind'] === 'evaluation';
282
+ }
283
+ function persistEvalGraphSidecarSafely(report, outputDir, sourcePath) {
284
+ if (!isEvaluationReport(report))
285
+ return;
286
+ try {
287
+ persistEvalGraphSidecar({ report, outputDir, sourcePath, fileStem: report.id });
288
+ }
289
+ catch (err) {
290
+ const message = err instanceof Error ? err.message : String(err);
291
+ process.stderr.write(`[omk] 写入 eval 图谱失败:${message}\n`);
292
+ }
293
+ }
279
294
  export function persistReport(report, outputDir) {
280
295
  if (!outputDir)
281
296
  return null;
@@ -283,28 +298,32 @@ export function persistReport(report, outputDir) {
283
298
  mkdirSync(outputDir, { recursive: true });
284
299
  const filePath = reportFilePath(outputDir, report.id);
285
300
  writeFileSync(filePath, JSON.stringify(report, null, 2));
301
+ persistEvalGraphSidecarSafely(report, outputDir, filePath);
286
302
  // 产物发现索引:报告落项目本地后,best-effort 追加全局轻卡片,让 omk studio 跨项目聚合成机器级总览。
287
303
  // 永不抛、永不阻断报告落盘(正文是 source of truth)。
288
304
  indexReportWrite(report, filePath, outputDir);
289
305
  return filePath;
290
306
  }
291
307
  /**
292
- * run id 的时间戳后缀 `YYYYMMDD-HHmmss-rand4`。
308
+ * run id 的时间戳后缀 `YYYYMMDDTHHmmss-rand4`。
293
309
  * 含秒 + 4 位随机:id 是 run 标签(非测量数),但被 studio 机器级 dedup 与 managed 证据 (reportId,
294
310
  * contentHash) 去重当唯一键用。分钟级会让跨项目 / 同分钟重跑撞同 id → 索引静默顶掉一份、managed 错并一条。
295
311
  * 秒+随机根治撞名,保证每次 run 全局唯一。供 generateRunId 与 evolve 合并 id 共用,避免靠 split 反解格式。
296
312
  */
297
313
  export function runIdSuffix() {
298
- const d = new Date();
299
- const pad = (n) => String(n).padStart(2, '0');
300
- const date = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
301
- const time = `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
302
- const rand = Math.random().toString(36).slice(2, 6);
303
- return `${date}-${time}-${rand}`;
314
+ return `${runTimestamp()}-${randomRunToken()}`;
315
+ }
316
+ function safeRunSubject(subject) {
317
+ const sanitized = subject
318
+ .replaceAll(/[\\/:]/g, '-')
319
+ .replaceAll(/[^a-zA-Z0-9._@-]/g, '_')
320
+ .replace(/^-+|-+$/g, '');
321
+ return sanitized || 'run';
322
+ }
323
+ function primaryRunSubject(variants) {
324
+ const nonBaseline = variants.filter((variant) => variant !== 'baseline');
325
+ return nonBaseline.at(-1) ?? variants.at(-1) ?? 'run';
304
326
  }
305
327
  export function generateRunId(variants) {
306
- const variantPart = variants
307
- .map((variant) => variant.replaceAll(/[\\/:]/g, '-').replaceAll(/[^a-zA-Z0-9._@-]/g, '_'))
308
- .join('-vs-');
309
- return `${variantPart}-${runIdSuffix()}`;
328
+ return `${safeRunSubject(primaryRunSubject(variants))}-${runIdSuffix()}`;
310
329
  }
@@ -149,9 +149,10 @@ export function renderRunList(runs, lang = DEFAULT_LANG) {
149
149
  // 顶部 verdict pill 主导视觉,id+date 是身份,scores 是成绩,底部 meta 是元数据,
150
150
  // delete 默认隐藏在 hover 出现避免误点。批量报告共用同一组件,batch pill 替代 verdict。
151
151
  const formatDateFromId = (id, fallbackTs) => {
152
- // 兼容两代 run id 后缀:旧 `…YYYYMMDD-HHmm`、新 `…YYYYMMDD-HHmmss-rand4`(含秒+随机)。
152
+ // 兼容三代 run id 后缀:旧 `…YYYYMMDD-HHmm`、过渡期 `…YYYYMMDD-HHmmss-rand4`、
153
+ // 统一后 `…YYYYMMDDTHHmmss-rand4`(含秒+随机)。
153
154
  // 都从 id 直接派生本地展示时间(确定性,不走时区敏感的 toLocaleString);新增的秒段 + 随机后缀可选匹配。
154
- const idMatch = id.match(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(?:\d{2}-[a-z0-9]+)?$/);
155
+ const idMatch = id.match(/(\d{4})(\d{2})(\d{2})(?:T|-)(\d{2})(\d{2})(?:\d{2})?(?:-[a-z0-9]+)?$/);
155
156
  if (idMatch)
156
157
  return `${idMatch[2]}/${idMatch[3]} ${idMatch[4]}:${idMatch[5]}`;
157
158
  return fallbackTs ? new Date(fallbackTs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';