akm-cli 0.9.28-alpha.1 → 0.9.28-alpha.3
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/CHANGELOG.md +51 -0
- package/STABILITY.md +4 -0
- package/dist/assets/hints/cli-hints-full.md +3 -0
- package/dist/assets/hints/cli-hints-short.md +1 -0
- package/dist/assets/templates/html/metrics.html +977 -0
- package/dist/cli/shared.js +5 -4
- package/dist/cli.js +11 -1
- package/dist/commands/health/accept-rate.js +8 -4
- package/dist/commands/health/html-report.js +3 -8
- package/dist/commands/health/llm-usage.js +17 -6
- package/dist/commands/health/renderers.js +4 -4
- package/dist/commands/improve/consolidate/pair-pass.js +3 -0
- package/dist/commands/improve/distill.js +6 -0
- package/dist/commands/improve/improve-report.js +4 -2
- package/dist/commands/improve/improve.js +22 -10
- package/dist/commands/metrics/collect.js +439 -0
- package/dist/commands/metrics/html-report.js +82 -0
- package/dist/commands/metrics/md-report.js +44 -0
- package/dist/commands/metrics/metrics-cli.js +213 -0
- package/dist/commands/metrics/report-view.js +243 -0
- package/dist/commands/metrics/types.js +4 -0
- package/dist/commands/read/search.js +5 -0
- package/dist/indexer/indexer.js +64 -14
- package/dist/indexer/usage/usage-events.js +3 -1
- package/dist/integrations/session-logs/pre-filter.js +1 -0
- package/dist/llm/usage-persist.js +22 -11
- package/dist/llm/usage-telemetry.js +4 -0
- package/dist/output/html-render.js +15 -8
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/metrics.js +39 -0
- package/dist/output/text.js +2 -0
- package/dist/scripts/akm-migrate-node.js +281 -8
- package/dist/scripts/akm-migrate.js +281 -8
- package/dist/storage/repositories/index-utility-repository.js +24 -0
- package/dist/storage/repositories/metrics-repository.js +80 -0
- package/docs/reference/cli.md +63 -4
- package/docs/reference/data-and-telemetry.md +42 -8
- package/package.json +1 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { decodeLlmUsageRecord } from "../../llm/usage-telemetry.js";
|
|
5
|
+
import { decodeTaskHistoryMetadata } from "../../storage/repositories/task-history-repository.js";
|
|
6
|
+
import { computeWallTimeStats } from "../health/improve-metrics.js";
|
|
7
|
+
import { computeValenceScore } from "../improve/feedback-valence.js";
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
/** Utility is a score in [0, 1], reported in ten equal buckets. */
|
|
10
|
+
const UTILITY_BUCKETS = 10;
|
|
11
|
+
/** Mean result counts are rounded to two decimals. */
|
|
12
|
+
const MEAN_SCALE = 100;
|
|
13
|
+
/** `improve.eventRetentionDays` default (`runRetentionPurgePass`); 0 disables the purge. */
|
|
14
|
+
export const DEFAULT_EVENT_RETENTION_DAYS = 90;
|
|
15
|
+
// ── Filters ─────────────────────────────────────────────────────────────────
|
|
16
|
+
// ── Notes ───────────────────────────────────────────────────────────────────
|
|
17
|
+
/**
|
|
18
|
+
* One note per store whose retention is shorter than the window, naming the
|
|
19
|
+
* store, how long it keeps rows and where the data effectively starts. A
|
|
20
|
+
* retention of 0 or less means the store is never purged.
|
|
21
|
+
*/
|
|
22
|
+
export function retentionNotes(args) {
|
|
23
|
+
const stores = [
|
|
24
|
+
{ store: "usage_events (search, show, curate and feedback rows)", days: args.usageRetentionDays },
|
|
25
|
+
{ store: "events (selects, LLM calls and index runs)", days: args.eventRetentionDays },
|
|
26
|
+
];
|
|
27
|
+
const notes = [];
|
|
28
|
+
const sinceMs = Date.parse(args.sinceIso);
|
|
29
|
+
for (const { store, days } of stores) {
|
|
30
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
31
|
+
continue;
|
|
32
|
+
const effectiveMs = args.nowMs - days * DAY_MS;
|
|
33
|
+
if (sinceMs >= effectiveMs)
|
|
34
|
+
continue;
|
|
35
|
+
notes.push(`${store} keeps ${days} days, so the window starts ${args.sinceIso} but its data starts no earlier than ${new Date(effectiveMs).toISOString()}.`);
|
|
36
|
+
}
|
|
37
|
+
return notes;
|
|
38
|
+
}
|
|
39
|
+
// ── Row mapping ─────────────────────────────────────────────────────────────
|
|
40
|
+
/** `usage_events.created_at` (`YYYY-MM-DD HH:MM:SS`, UTC) as an ISO-8601 string. */
|
|
41
|
+
export function usageCreatedAtToIso(createdAt) {
|
|
42
|
+
const text = createdAt.includes("T") ? createdAt : `${createdAt.replace(" ", "T")}Z`;
|
|
43
|
+
const ms = Date.parse(text);
|
|
44
|
+
return Number.isNaN(ms) ? createdAt : new Date(ms).toISOString();
|
|
45
|
+
}
|
|
46
|
+
function parseMetadata(text) {
|
|
47
|
+
if (!text)
|
|
48
|
+
return {};
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(text);
|
|
51
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
52
|
+
? parsed
|
|
53
|
+
: {};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function finiteNumber(value) {
|
|
60
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** Map one `usage_events` row to the shape reported (and carried to the HTML dashboard). */
|
|
63
|
+
export function toUsageRow(row) {
|
|
64
|
+
const metadata = parseMetadata(row.metadata);
|
|
65
|
+
const resultCount = finiteNumber(metadata.resultCount);
|
|
66
|
+
const totalMs = finiteNumber(metadata.totalMs);
|
|
67
|
+
const reason = typeof metadata.reason === "string" && metadata.reason.trim() ? metadata.reason : undefined;
|
|
68
|
+
const tags = Array.isArray(metadata.tags)
|
|
69
|
+
? metadata.tags.filter((tag) => typeof tag === "string")
|
|
70
|
+
: undefined;
|
|
71
|
+
return {
|
|
72
|
+
id: row.id,
|
|
73
|
+
at: usageCreatedAtToIso(row.created_at),
|
|
74
|
+
eventType: row.event_type,
|
|
75
|
+
...(row.entry_ref ? { ref: row.entry_ref } : {}),
|
|
76
|
+
...(row.query ? { query: row.query } : {}),
|
|
77
|
+
...(row.signal === "positive" || row.signal === "negative" ? { signal: row.signal } : {}),
|
|
78
|
+
source: row.source,
|
|
79
|
+
...(resultCount !== undefined ? { resultCount } : {}),
|
|
80
|
+
...(totalMs !== undefined ? { totalMs } : {}),
|
|
81
|
+
...(reason !== undefined ? { reason } : {}),
|
|
82
|
+
...(tags && tags.length > 0 ? { tags } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `llm_usage` events summed per UTC day x stage x process x engine x model x
|
|
87
|
+
* outcome: the rows the dashboard re-aggregates. One row per call would be the
|
|
88
|
+
* largest thing in the page, and the page only ever filters them by date.
|
|
89
|
+
* Events that do not decode are skipped; rows come out in first-seen order.
|
|
90
|
+
*/
|
|
91
|
+
export function llmRowsFromEvents(events) {
|
|
92
|
+
const rows = new Map();
|
|
93
|
+
for (const event of events) {
|
|
94
|
+
const record = decodeLlmUsageRecord(event.metadata);
|
|
95
|
+
if (!record)
|
|
96
|
+
continue;
|
|
97
|
+
const day = event.ts.slice(0, 10);
|
|
98
|
+
const key = JSON.stringify([day, record.stage, record.process, record.engine, record.model, record.outcome]);
|
|
99
|
+
let row = rows.get(key);
|
|
100
|
+
if (!row) {
|
|
101
|
+
row = {
|
|
102
|
+
day,
|
|
103
|
+
...(record.stage !== undefined ? { stage: record.stage } : {}),
|
|
104
|
+
...(record.process !== undefined ? { process: record.process } : {}),
|
|
105
|
+
...(record.engine !== undefined ? { engine: record.engine } : {}),
|
|
106
|
+
...(record.model !== undefined ? { model: record.model } : {}),
|
|
107
|
+
outcome: record.outcome,
|
|
108
|
+
calls: 0,
|
|
109
|
+
durationMs: 0,
|
|
110
|
+
promptTokens: 0,
|
|
111
|
+
completionTokens: 0,
|
|
112
|
+
totalTokens: 0,
|
|
113
|
+
reasoningTokens: 0,
|
|
114
|
+
};
|
|
115
|
+
rows.set(key, row);
|
|
116
|
+
}
|
|
117
|
+
row.calls += 1;
|
|
118
|
+
row.durationMs += record.durationMs;
|
|
119
|
+
row.promptTokens += record.promptTokens ?? 0;
|
|
120
|
+
row.completionTokens += record.completionTokens ?? 0;
|
|
121
|
+
// A call without a total counts as prompt + completion, as the dashboard did per call.
|
|
122
|
+
row.totalTokens += record.totalTokens ?? (record.promptTokens ?? 0) + (record.completionTokens ?? 0);
|
|
123
|
+
row.reasoningTokens += record.reasoningTokens ?? 0;
|
|
124
|
+
}
|
|
125
|
+
return [...rows.values()];
|
|
126
|
+
}
|
|
127
|
+
/** `index_completed` events as index runs; rows without a numeric `totalMs` are skipped. */
|
|
128
|
+
export function indexRunsFromEvents(events) {
|
|
129
|
+
const runs = [];
|
|
130
|
+
for (const event of events) {
|
|
131
|
+
const metadata = parseMetadata(event.metadata_json);
|
|
132
|
+
const totalMs = finiteNumber(metadata.totalMs);
|
|
133
|
+
if (totalMs === undefined)
|
|
134
|
+
continue;
|
|
135
|
+
runs.push({ at: event.ts, mode: metadata.mode === "full" ? "full" : "incremental", totalMs });
|
|
136
|
+
}
|
|
137
|
+
return runs;
|
|
138
|
+
}
|
|
139
|
+
// ── Small helpers ───────────────────────────────────────────────────────────
|
|
140
|
+
function median(values) {
|
|
141
|
+
return values.length === 0 ? null : computeWallTimeStats(values).medianMs;
|
|
142
|
+
}
|
|
143
|
+
function mean(values) {
|
|
144
|
+
if (values.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * MEAN_SCALE) / MEAN_SCALE;
|
|
147
|
+
}
|
|
148
|
+
function byCountThenKey(key) {
|
|
149
|
+
return (a, b) => b.count - a.count || key(a).localeCompare(key(b));
|
|
150
|
+
}
|
|
151
|
+
// ── Sections ────────────────────────────────────────────────────────────────
|
|
152
|
+
function buildUsage(input, rows) {
|
|
153
|
+
// A search or curate writes one summary row (no ref) plus one row per hit;
|
|
154
|
+
// the summary row is the unit counted.
|
|
155
|
+
const isUnit = (row) => row.ref === undefined;
|
|
156
|
+
const days = new Map();
|
|
157
|
+
const bump = (at, field) => {
|
|
158
|
+
const day = at.slice(0, 10);
|
|
159
|
+
const entry = days.get(day) ?? { day, search: 0, show: 0, curate: 0, feedback: 0 };
|
|
160
|
+
entry[field] += 1;
|
|
161
|
+
days.set(day, entry);
|
|
162
|
+
};
|
|
163
|
+
const assets = new Map();
|
|
164
|
+
const asset = (ref) => {
|
|
165
|
+
let entry = assets.get(ref);
|
|
166
|
+
if (!entry) {
|
|
167
|
+
entry = { ref, shows: 0, searchHits: 0, selects: 0, positive: 0, negative: 0 };
|
|
168
|
+
assets.set(ref, entry);
|
|
169
|
+
}
|
|
170
|
+
return entry;
|
|
171
|
+
};
|
|
172
|
+
const touch = (entry, at) => {
|
|
173
|
+
if (entry.lastUsedAt === undefined || at > entry.lastUsedAt)
|
|
174
|
+
entry.lastUsedAt = at;
|
|
175
|
+
};
|
|
176
|
+
const queries = new Map();
|
|
177
|
+
const zeroQueries = new Map();
|
|
178
|
+
const bySource = {};
|
|
179
|
+
const searchMs = [];
|
|
180
|
+
let searches = 0;
|
|
181
|
+
let searchesWithHits = 0;
|
|
182
|
+
let zeroResultSearches = 0;
|
|
183
|
+
let shows = 0;
|
|
184
|
+
let curates = 0;
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
bySource[row.source] = (bySource[row.source] ?? 0) + 1;
|
|
187
|
+
if (row.eventType === "search") {
|
|
188
|
+
if (row.ref !== undefined) {
|
|
189
|
+
const entry = asset(row.ref);
|
|
190
|
+
entry.searchHits += 1;
|
|
191
|
+
touch(entry, row.at);
|
|
192
|
+
}
|
|
193
|
+
if (!isUnit(row))
|
|
194
|
+
continue;
|
|
195
|
+
searches += 1;
|
|
196
|
+
bump(row.at, "search");
|
|
197
|
+
if ((row.resultCount ?? 0) > 0)
|
|
198
|
+
searchesWithHits += 1;
|
|
199
|
+
if (row.resultCount === 0)
|
|
200
|
+
zeroResultSearches += 1;
|
|
201
|
+
if (row.totalMs !== undefined)
|
|
202
|
+
searchMs.push(row.totalMs);
|
|
203
|
+
const query = row.query?.trim();
|
|
204
|
+
if (query) {
|
|
205
|
+
const entry = queries.get(query) ?? { count: 0, results: [], lastAt: row.at };
|
|
206
|
+
entry.count += 1;
|
|
207
|
+
if (row.resultCount !== undefined)
|
|
208
|
+
entry.results.push(row.resultCount);
|
|
209
|
+
if (row.at > entry.lastAt)
|
|
210
|
+
entry.lastAt = row.at;
|
|
211
|
+
queries.set(query, entry);
|
|
212
|
+
if (row.resultCount === 0) {
|
|
213
|
+
const zero = zeroQueries.get(query) ?? { count: 0, lastAt: row.at };
|
|
214
|
+
zero.count += 1;
|
|
215
|
+
if (row.at > zero.lastAt)
|
|
216
|
+
zero.lastAt = row.at;
|
|
217
|
+
zeroQueries.set(query, zero);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (row.eventType === "curate") {
|
|
222
|
+
if (isUnit(row)) {
|
|
223
|
+
curates += 1;
|
|
224
|
+
bump(row.at, "curate");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (row.eventType === "show") {
|
|
228
|
+
shows += 1;
|
|
229
|
+
bump(row.at, "show");
|
|
230
|
+
if (row.ref !== undefined) {
|
|
231
|
+
const entry = asset(row.ref);
|
|
232
|
+
entry.shows += 1;
|
|
233
|
+
touch(entry, row.at);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (row.eventType === "feedback") {
|
|
237
|
+
bump(row.at, "feedback");
|
|
238
|
+
if (row.ref !== undefined && row.signal !== undefined) {
|
|
239
|
+
const entry = asset(row.ref);
|
|
240
|
+
entry[row.signal] += 1;
|
|
241
|
+
touch(entry, row.at);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const select of input.selects) {
|
|
246
|
+
const entry = asset(select.ref);
|
|
247
|
+
entry.selects += 1;
|
|
248
|
+
touch(entry, select.ts);
|
|
249
|
+
}
|
|
250
|
+
const toQueryCount = (query, entry) => ({
|
|
251
|
+
query,
|
|
252
|
+
count: entry.count,
|
|
253
|
+
avgResults: mean(entry.results),
|
|
254
|
+
lastAt: entry.lastAt,
|
|
255
|
+
});
|
|
256
|
+
return {
|
|
257
|
+
totals: {
|
|
258
|
+
searches,
|
|
259
|
+
shows,
|
|
260
|
+
curates,
|
|
261
|
+
selects: input.selects.length,
|
|
262
|
+
zeroResultSearches,
|
|
263
|
+
distinctAssets: assets.size,
|
|
264
|
+
distinctQueries: queries.size,
|
|
265
|
+
},
|
|
266
|
+
selectRate: searchesWithHits > 0 ? input.selects.length / searchesWithHits : null,
|
|
267
|
+
searchMedianMs: median(searchMs),
|
|
268
|
+
daily: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
|
269
|
+
topAssets: [...assets.values()]
|
|
270
|
+
.sort((a, b) => b.shows - a.shows || b.searchHits - a.searchHits || b.selects - a.selects || a.ref.localeCompare(b.ref))
|
|
271
|
+
.slice(0, input.top),
|
|
272
|
+
topQueries: [...queries.entries()]
|
|
273
|
+
.map(([query, entry]) => toQueryCount(query, entry))
|
|
274
|
+
.sort(byCountThenKey((item) => item.query))
|
|
275
|
+
.slice(0, input.top),
|
|
276
|
+
zeroResultQueries: [...zeroQueries.entries()]
|
|
277
|
+
.map(([query, entry]) => ({ query, count: entry.count, avgResults: 0, lastAt: entry.lastAt }))
|
|
278
|
+
.sort(byCountThenKey((item) => item.query))
|
|
279
|
+
.slice(0, input.top),
|
|
280
|
+
bySource,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function buildFeedback(top, rows) {
|
|
284
|
+
const totals = { positive: 0, negative: 0 };
|
|
285
|
+
const byAsset = new Map();
|
|
286
|
+
const byTag = {};
|
|
287
|
+
const negatives = [];
|
|
288
|
+
for (const row of rows) {
|
|
289
|
+
if (row.eventType !== "feedback" || row.signal === undefined)
|
|
290
|
+
continue;
|
|
291
|
+
totals[row.signal] += 1;
|
|
292
|
+
if (row.ref !== undefined) {
|
|
293
|
+
const entry = byAsset.get(row.ref) ?? { positive: 0, negative: 0, lastAt: row.at };
|
|
294
|
+
entry[row.signal] += 1;
|
|
295
|
+
if (row.at > entry.lastAt)
|
|
296
|
+
entry.lastAt = row.at;
|
|
297
|
+
byAsset.set(row.ref, entry);
|
|
298
|
+
}
|
|
299
|
+
for (const tag of row.tags ?? []) {
|
|
300
|
+
byTag[tag] ??= { positive: 0, negative: 0 };
|
|
301
|
+
byTag[tag][row.signal] += 1;
|
|
302
|
+
}
|
|
303
|
+
if (row.signal === "negative" && row.ref !== undefined) {
|
|
304
|
+
negatives.push({
|
|
305
|
+
id: row.id,
|
|
306
|
+
ref: row.ref,
|
|
307
|
+
at: row.at,
|
|
308
|
+
...(row.reason !== undefined ? { reason: row.reason } : {}),
|
|
309
|
+
...(row.tags ? { tags: row.tags } : {}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const assetRows = [...byAsset.entries()].map(([ref, entry]) => ({
|
|
314
|
+
ref,
|
|
315
|
+
positive: entry.positive,
|
|
316
|
+
negative: entry.negative,
|
|
317
|
+
valence: computeValenceScore(entry).valence,
|
|
318
|
+
lastAt: entry.lastAt,
|
|
319
|
+
}));
|
|
320
|
+
return {
|
|
321
|
+
totals,
|
|
322
|
+
byAsset: assetRows
|
|
323
|
+
.sort((a, b) => b.positive + b.negative - (a.positive + a.negative) || a.ref.localeCompare(b.ref))
|
|
324
|
+
.slice(0, top),
|
|
325
|
+
byTag: Object.fromEntries(Object.entries(byTag).sort(([a], [b]) => a.localeCompare(b))),
|
|
326
|
+
recentNegative: negatives
|
|
327
|
+
.sort((a, b) => b.at.localeCompare(a.at) || b.id - a.id)
|
|
328
|
+
.slice(0, top)
|
|
329
|
+
.map(({ id: _id, ...rest }) => rest),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function buildUtility(input) {
|
|
333
|
+
const histogram = Array.from({ length: UTILITY_BUCKETS }, (_, index) => ({
|
|
334
|
+
bucket: `${(index / UTILITY_BUCKETS).toFixed(1)}-${((index + 1) / UTILITY_BUCKETS).toFixed(1)}`,
|
|
335
|
+
count: 0,
|
|
336
|
+
}));
|
|
337
|
+
const scored = [];
|
|
338
|
+
let neverUsed = 0;
|
|
339
|
+
for (const entry of input.utility ?? []) {
|
|
340
|
+
if (!entry.score) {
|
|
341
|
+
neverUsed += 1;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const clamped = Math.min(1, Math.max(0, entry.score.utility));
|
|
345
|
+
const bucket = histogram[Math.min(UTILITY_BUCKETS - 1, Math.floor(clamped * UTILITY_BUCKETS))];
|
|
346
|
+
if (bucket)
|
|
347
|
+
bucket.count += 1;
|
|
348
|
+
scored.push({
|
|
349
|
+
ref: entry.ref,
|
|
350
|
+
utility: entry.score.utility,
|
|
351
|
+
showCount: entry.score.showCount,
|
|
352
|
+
searchCount: entry.score.searchCount,
|
|
353
|
+
selectRate: entry.score.selectRate,
|
|
354
|
+
...(entry.score.lastUsedAt !== undefined ? { lastUsedAt: entry.score.lastUsedAt } : {}),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
count: scored.length,
|
|
359
|
+
histogram,
|
|
360
|
+
lowest: [...scored].sort((a, b) => a.utility - b.utility || a.ref.localeCompare(b.ref)).slice(0, input.top),
|
|
361
|
+
highest: [...scored].sort((a, b) => b.utility - a.utility || a.ref.localeCompare(b.ref)).slice(0, input.top),
|
|
362
|
+
neverUsed,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function taskDurationMs(row) {
|
|
366
|
+
try {
|
|
367
|
+
return decodeTaskHistoryMetadata(row.metadata_json).durationMs;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function buildTasks(top, rows) {
|
|
374
|
+
const byTask = new Map();
|
|
375
|
+
let failed = 0;
|
|
376
|
+
for (const row of rows) {
|
|
377
|
+
const entry = byTask.get(row.task_id) ?? { runs: 0, failed: 0, durations: [] };
|
|
378
|
+
entry.runs += 1;
|
|
379
|
+
if (row.status === "failed") {
|
|
380
|
+
entry.failed += 1;
|
|
381
|
+
failed += 1;
|
|
382
|
+
}
|
|
383
|
+
const durationMs = taskDurationMs(row);
|
|
384
|
+
if (durationMs !== undefined)
|
|
385
|
+
entry.durations.push(durationMs);
|
|
386
|
+
byTask.set(row.task_id, entry);
|
|
387
|
+
}
|
|
388
|
+
const summaries = [...byTask.entries()].map(([taskId, entry]) => ({
|
|
389
|
+
taskId,
|
|
390
|
+
runs: entry.runs,
|
|
391
|
+
failed: entry.failed,
|
|
392
|
+
medianMs: median(entry.durations),
|
|
393
|
+
}));
|
|
394
|
+
return {
|
|
395
|
+
runs: rows.length,
|
|
396
|
+
failed,
|
|
397
|
+
// Same definition as `akm health`'s task fail rate: failed runs over every run in the window.
|
|
398
|
+
failRate: rows.length > 0 ? failed / rows.length : null,
|
|
399
|
+
byTask: summaries.sort((a, b) => b.runs - a.runs || a.taskId.localeCompare(b.taskId)).slice(0, top),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function buildIndex(top, runs) {
|
|
403
|
+
return {
|
|
404
|
+
runs: runs.length,
|
|
405
|
+
medianMs: median(runs.map((run) => run.totalMs)),
|
|
406
|
+
// Input is oldest first; reversing before the stable sort puts later rows first within one timestamp.
|
|
407
|
+
recent: [...runs]
|
|
408
|
+
.reverse()
|
|
409
|
+
.sort((a, b) => b.at.localeCompare(a.at))
|
|
410
|
+
.slice(0, top),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
414
|
+
export function buildMetricsResult(input) {
|
|
415
|
+
const rows = input.usage.map(toUsageRow);
|
|
416
|
+
const outcomes = input.outcomes.slice(0, input.top).map((row) => ({
|
|
417
|
+
ref: row.asset_ref,
|
|
418
|
+
outcomeScore: row.outcome_score,
|
|
419
|
+
retrievalCount: row.retrieval_count,
|
|
420
|
+
negativeFeedbackCount: row.negative_feedback_count,
|
|
421
|
+
acceptedChangeCount: row.accepted_change_count,
|
|
422
|
+
}));
|
|
423
|
+
return {
|
|
424
|
+
schemaVersion: 1,
|
|
425
|
+
window: input.window,
|
|
426
|
+
filters: { source: input.source },
|
|
427
|
+
usage: buildUsage(input, rows),
|
|
428
|
+
feedback: buildFeedback(input.top, rows),
|
|
429
|
+
utility: buildUtility(input),
|
|
430
|
+
outcomes: { lowestOutcome: outcomes },
|
|
431
|
+
llm: input.llm,
|
|
432
|
+
index: buildIndex(input.top, input.indexRuns),
|
|
433
|
+
tasks: buildTasks(input.top, input.tasks),
|
|
434
|
+
proposals: input.proposals,
|
|
435
|
+
workflows: input.workflows,
|
|
436
|
+
...(input.includeRows ? { rows: { usage: rows, llm: input.llmRows } } : {}),
|
|
437
|
+
notes: input.notes,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `akm metrics --format html` — the self-contained dashboard.
|
|
6
|
+
*
|
|
7
|
+
* The page carries the window's data in a JSON island
|
|
8
|
+
* (`<script type="application/json" id="akm-data">`) and renders every panel
|
|
9
|
+
* client-side from it, so the viewer's filters re-aggregate without re-running
|
|
10
|
+
* the command. The template is `src/assets/templates/html/metrics.html`.
|
|
11
|
+
*
|
|
12
|
+
* Determinism: nothing here reads the clock. `%%GENERATED_AT%%` is
|
|
13
|
+
* `window.until`, so identical input gives byte-identical output.
|
|
14
|
+
*/
|
|
15
|
+
import { escapeHtml, isoTimeTag, renderHtml, resolveTemplatePath } from "../../output/html-render.js";
|
|
16
|
+
import { pkgVersion } from "../../version.js";
|
|
17
|
+
import { buildEchartsTag } from "../health/html-report.js";
|
|
18
|
+
import { isMetricsResult } from "./report-view.js";
|
|
19
|
+
const esc = escapeHtml;
|
|
20
|
+
/** Most `rows.usage` entries the page carries; older rows are dropped with a note. */
|
|
21
|
+
export const MAX_HTML_USAGE_ROWS = 50_000;
|
|
22
|
+
/**
|
|
23
|
+
* The result as the page receives it: `rows` always present (empty when the
|
|
24
|
+
* caller left them out), usage rows cut to the most recent
|
|
25
|
+
* {@link MAX_HTML_USAGE_ROWS}, and the query text of per-hit rows (those with a
|
|
26
|
+
* ref) moved into one `rows.queries` table so a query that surfaced many assets
|
|
27
|
+
* is written once; the page puts it back on load. Never mutates the input.
|
|
28
|
+
*/
|
|
29
|
+
function toPageData(result) {
|
|
30
|
+
const notes = [...result.notes];
|
|
31
|
+
let usage = result.rows?.usage ?? [];
|
|
32
|
+
if (usage.length > MAX_HTML_USAGE_ROWS) {
|
|
33
|
+
// ISO timestamps sort lexically; id breaks ties so the cut is stable.
|
|
34
|
+
usage = [...usage].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.id - b.id)).slice(-MAX_HTML_USAGE_ROWS);
|
|
35
|
+
notes.push(`The dashboard shows the most recent ${MAX_HTML_USAGE_ROWS} usage rows of this window; older rows were left out of the page (totals in the JSON output are complete).`);
|
|
36
|
+
}
|
|
37
|
+
const queries = [];
|
|
38
|
+
const queryIndex = new Map();
|
|
39
|
+
const pageUsage = usage.map((row) => {
|
|
40
|
+
if (row.ref === undefined || row.query === undefined)
|
|
41
|
+
return row;
|
|
42
|
+
const { query, ...rest } = row;
|
|
43
|
+
let q = queryIndex.get(query);
|
|
44
|
+
if (q === undefined) {
|
|
45
|
+
q = queries.length;
|
|
46
|
+
queries.push(query);
|
|
47
|
+
queryIndex.set(query, q);
|
|
48
|
+
}
|
|
49
|
+
return { ...rest, q };
|
|
50
|
+
});
|
|
51
|
+
return { ...result, notes, rows: { usage: pageUsage, llm: result.rows?.llm ?? [], queries } };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Serialize for an inline `<script type="application/json">`. Escaping `<` is
|
|
55
|
+
* enough: it stops `</script>` and `<!--` in a query or reason from ending the
|
|
56
|
+
* island, and `JSON.parse` turns `<` back into `<`.
|
|
57
|
+
*/
|
|
58
|
+
function islandJson(value) {
|
|
59
|
+
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
|
60
|
+
}
|
|
61
|
+
export function buildMetricsHtmlReplacements(result) {
|
|
62
|
+
const { filters, window: win } = result;
|
|
63
|
+
return {
|
|
64
|
+
"%%ECHARTS_TAG%%": buildEchartsTag(),
|
|
65
|
+
"%%REPORT_TITLE%%": "akm metrics",
|
|
66
|
+
"%%WINDOW_HTML%%": `${isoTimeTag(win.since)} → ${isoTimeTag(win.until)}`,
|
|
67
|
+
"%%FILTERS_HTML%%": `source: ${esc(filters.source)}`,
|
|
68
|
+
"%%GENERATED_AT%%": esc(win.until),
|
|
69
|
+
"%%AKM_VERSION%%": esc(pkgVersion),
|
|
70
|
+
"%%DATA_JSON%%": islandJson(toPageData(result)),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `--format html` renderer for `akm metrics`, called directly from
|
|
75
|
+
* `cli/shared.ts`. Returns `null` for a payload that is not a metrics result so
|
|
76
|
+
* the generic renderer takes over.
|
|
77
|
+
*/
|
|
78
|
+
export function renderMetricsHtml(result) {
|
|
79
|
+
if (!isMetricsResult(result))
|
|
80
|
+
return null;
|
|
81
|
+
return renderHtml(resolveTemplatePath("metrics"), buildMetricsHtmlReplacements(result));
|
|
82
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { registerMdRenderer } from "../../output/render-registry.js";
|
|
5
|
+
import { buildMetricsView, isMetricsResult } from "./report-view.js";
|
|
6
|
+
/** Escape a cell so a query or reason containing `|` or a newline cannot break the table. */
|
|
7
|
+
function cell(value) {
|
|
8
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
9
|
+
}
|
|
10
|
+
function renderTable(table) {
|
|
11
|
+
const row = (cells) => `| ${cells.map(cell).join(" | ")} |`;
|
|
12
|
+
return [
|
|
13
|
+
`### ${table.title}`,
|
|
14
|
+
"",
|
|
15
|
+
row(table.headers),
|
|
16
|
+
row(table.headers.map(() => "---")),
|
|
17
|
+
...table.rows.map(row),
|
|
18
|
+
"",
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
export function renderMetricsMd(result, detail) {
|
|
22
|
+
if (!isMetricsResult(result))
|
|
23
|
+
return null;
|
|
24
|
+
const view = buildMetricsView(result, detail);
|
|
25
|
+
const lines = ["# akm metrics", "", `- **Window:** ${view.window}`, `- **Filters:** ${view.filters}`, ""];
|
|
26
|
+
for (const section of view.sections) {
|
|
27
|
+
lines.push(`## ${section.title}`, "");
|
|
28
|
+
for (const [label, value] of section.facts)
|
|
29
|
+
lines.push(`- **${label}:** ${value}`);
|
|
30
|
+
if (section.facts.length > 0)
|
|
31
|
+
lines.push("");
|
|
32
|
+
for (const table of section.tables) {
|
|
33
|
+
if (table.rows.length > 0)
|
|
34
|
+
lines.push(...renderTable(table));
|
|
35
|
+
else if (!table.hideWhenEmpty)
|
|
36
|
+
lines.push(`### ${table.title}`, "", "_none_", "");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (view.notes.length > 0) {
|
|
40
|
+
lines.push("## Notes", "", ...view.notes.map((n) => `- ${n}`), "");
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n").trimEnd();
|
|
43
|
+
}
|
|
44
|
+
registerMdRenderer("metrics", renderMetricsMd);
|