@ishlabs/cli 0.20.0 → 0.21.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.
- package/dist/commands/study.js +250 -13
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +2 -0
- package/dist/lib/docs.js +190 -1
- package/dist/lib/output.d.ts +18 -0
- package/dist/lib/output.js +217 -1
- package/dist/lib/skill-content.js +68 -0
- package/dist/lib/study-participants.d.ts +13 -0
- package/dist/lib/study-participants.js +13 -0
- package/dist/lib/study-results-filters.d.ts +91 -0
- package/dist/lib/study-results-filters.js +538 -0
- package/dist/lib/study-results-projections.d.ts +122 -0
- package/dist/lib/study-results-projections.js +577 -0
- package/package.json +1 -1
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure projection builders for `ish study results --group-by <kind>`.
|
|
3
|
+
*
|
|
4
|
+
* Each `buildStudyResultsPer<Kind>` consumes a `FilteredResults` (the output
|
|
5
|
+
* of `applyResultsFilters` in `study-results-filters.ts`) and returns a
|
|
6
|
+
* plain JSON-serialisable value. The surface (T5) hands the result to
|
|
7
|
+
* `output(..., json, { preProjected: true })` for JSON, or to
|
|
8
|
+
* `formatStudyResultsGroupBy` (T6) for human mode.
|
|
9
|
+
*
|
|
10
|
+
* Per-iteration is the only projection that wraps a `{study, slices, ...}`
|
|
11
|
+
* envelope; the others return plain arrays of slice objects. The surface
|
|
12
|
+
* attaches `totals_unfiltered` and `warnings` from the same `FilteredResults`
|
|
13
|
+
* for the array projections.
|
|
14
|
+
*
|
|
15
|
+
* Conventions mirror `buildStudyResultsEnvelope` (`output.ts:1081`) and
|
|
16
|
+
* `buildStudyResultsSummary` (`output.ts:1292`):
|
|
17
|
+
* - deterministic field order (object literals are emitted in source order)
|
|
18
|
+
* - stable empties: empty arrays, never `null` for "no rows yet"
|
|
19
|
+
* - sample_comments capped at 5 per group, truncated to 200 chars
|
|
20
|
+
* - sentiment histograms are { label → count } records
|
|
21
|
+
* - participant_aliases capped at 10 per group
|
|
22
|
+
*
|
|
23
|
+
* Has no IO and no console side-effects.
|
|
24
|
+
*/
|
|
25
|
+
import { deterministicAlias, ALIAS_PREFIX } from "./alias-store.js";
|
|
26
|
+
const SAMPLE_COMMENT_CAP = 5;
|
|
27
|
+
const SAMPLE_COMMENT_MAX_LEN = 200;
|
|
28
|
+
const PARTICIPANT_ALIAS_CAP = 10;
|
|
29
|
+
const SAMPLE_REPLY_CAP = 5;
|
|
30
|
+
const SAMPLE_REPLY_MAX_LEN = 200;
|
|
31
|
+
const UNMATCHED_BUCKET = "_unmatched";
|
|
32
|
+
// ---------- helpers ---------------------------------------------------------
|
|
33
|
+
function asRecord(v) {
|
|
34
|
+
return v && typeof v === "object" && !Array.isArray(v)
|
|
35
|
+
? v
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
function asArray(v) {
|
|
39
|
+
return Array.isArray(v) ? v : [];
|
|
40
|
+
}
|
|
41
|
+
function asString(v) {
|
|
42
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
43
|
+
}
|
|
44
|
+
function truncate(str, maxLen) {
|
|
45
|
+
if (str.length <= maxLen)
|
|
46
|
+
return str;
|
|
47
|
+
return str.slice(0, maxLen - 3) + "...";
|
|
48
|
+
}
|
|
49
|
+
function participantAlias(participant) {
|
|
50
|
+
const id = asString(participant.id);
|
|
51
|
+
return id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
|
|
52
|
+
}
|
|
53
|
+
function studyHeader(filtered) {
|
|
54
|
+
const study = filtered.study;
|
|
55
|
+
const id = asString(study.id);
|
|
56
|
+
return {
|
|
57
|
+
alias: id ? deterministicAlias(ALIAS_PREFIX.study, id) : null,
|
|
58
|
+
name: asString(study.name) ?? null,
|
|
59
|
+
modality: asString(study.modality) ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function readSentimentLabel(interaction) {
|
|
63
|
+
const s = asRecord(interaction.sentiment);
|
|
64
|
+
return s ? asString(s.label) : null;
|
|
65
|
+
}
|
|
66
|
+
function readEngagement(interaction) {
|
|
67
|
+
// Media interactions carry `engagement` either as a top-level string
|
|
68
|
+
// ("engaged" | "drifted" | "abandoned") or as an object wrapping a `level`.
|
|
69
|
+
const e = interaction.engagement;
|
|
70
|
+
if (typeof e === "string" && e.length > 0)
|
|
71
|
+
return e;
|
|
72
|
+
const er = asRecord(e);
|
|
73
|
+
if (!er)
|
|
74
|
+
return null;
|
|
75
|
+
return asString(er.level) ?? asString(er.label);
|
|
76
|
+
}
|
|
77
|
+
function firstActionData(interaction) {
|
|
78
|
+
const actions = asArray(interaction.actions);
|
|
79
|
+
if (actions.length === 0)
|
|
80
|
+
return {};
|
|
81
|
+
const first = asRecord(actions[0]);
|
|
82
|
+
if (!first)
|
|
83
|
+
return {};
|
|
84
|
+
return asRecord(first.data) ?? {};
|
|
85
|
+
}
|
|
86
|
+
function pushSentiment(hist, label) {
|
|
87
|
+
if (!label)
|
|
88
|
+
return;
|
|
89
|
+
hist[label] = (hist[label] ?? 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
function pushEngagement(hist, label) {
|
|
92
|
+
if (!label)
|
|
93
|
+
return;
|
|
94
|
+
hist[label] = (hist[label] ?? 0) + 1;
|
|
95
|
+
}
|
|
96
|
+
function collectComment(bucket, interaction) {
|
|
97
|
+
if (bucket.length >= SAMPLE_COMMENT_CAP)
|
|
98
|
+
return;
|
|
99
|
+
const c = asString(interaction.comment);
|
|
100
|
+
if (!c)
|
|
101
|
+
return;
|
|
102
|
+
bucket.push(truncate(c, SAMPLE_COMMENT_MAX_LEN));
|
|
103
|
+
}
|
|
104
|
+
function collectParticipantAlias(bucket, seen, participant) {
|
|
105
|
+
if (bucket.length >= PARTICIPANT_ALIAS_CAP)
|
|
106
|
+
return;
|
|
107
|
+
const alias = participantAlias(participant);
|
|
108
|
+
if (!alias || seen.has(alias))
|
|
109
|
+
return;
|
|
110
|
+
seen.add(alias);
|
|
111
|
+
bucket.push(alias);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `--group-by iteration` — one slice per iteration that has any surviving
|
|
115
|
+
* participants. Slices are ordered by the iteration order on the study
|
|
116
|
+
* (so callers see them in the same order as `ish study get`).
|
|
117
|
+
*
|
|
118
|
+
* Unlike the array-returning projections, this one wraps a stable envelope
|
|
119
|
+
* with `totals_unfiltered` + `warnings` because per-iteration is the
|
|
120
|
+
* "default agent slice" — the one most likely to be piped directly without
|
|
121
|
+
* the surface re-wrapping.
|
|
122
|
+
*/
|
|
123
|
+
export function buildStudyResultsPerIteration(filtered) {
|
|
124
|
+
const iterations = asArray(filtered.study.iterations);
|
|
125
|
+
const order = [];
|
|
126
|
+
for (const raw of iterations) {
|
|
127
|
+
const r = asRecord(raw);
|
|
128
|
+
const id = r ? asString(r.id) : null;
|
|
129
|
+
if (!id)
|
|
130
|
+
continue;
|
|
131
|
+
order.push({ id, label: r ? asString(r.label) : null });
|
|
132
|
+
}
|
|
133
|
+
const byIteration = new Map();
|
|
134
|
+
const actionCounts = new Map();
|
|
135
|
+
for (const o of order) {
|
|
136
|
+
byIteration.set(o.id, {
|
|
137
|
+
iteration_id: o.id,
|
|
138
|
+
iteration_label: o.label,
|
|
139
|
+
participant_count: 0,
|
|
140
|
+
interaction_count: 0,
|
|
141
|
+
sentiment: {},
|
|
142
|
+
sample_comments: [],
|
|
143
|
+
top_actions: [],
|
|
144
|
+
});
|
|
145
|
+
actionCounts.set(o.id, new Map());
|
|
146
|
+
}
|
|
147
|
+
for (const p of filtered.participants) {
|
|
148
|
+
const iterId = asString(p.iteration_id);
|
|
149
|
+
if (!iterId)
|
|
150
|
+
continue;
|
|
151
|
+
const slice = byIteration.get(iterId);
|
|
152
|
+
if (!slice)
|
|
153
|
+
continue;
|
|
154
|
+
slice.participant_count += 1;
|
|
155
|
+
for (const raw of asArray(p.interactions)) {
|
|
156
|
+
const ix = asRecord(raw);
|
|
157
|
+
if (!ix)
|
|
158
|
+
continue;
|
|
159
|
+
slice.interaction_count += 1;
|
|
160
|
+
pushSentiment(slice.sentiment, readSentimentLabel(ix));
|
|
161
|
+
collectComment(slice.sample_comments, ix);
|
|
162
|
+
const counter = actionCounts.get(iterId);
|
|
163
|
+
for (const araw of asArray(ix.actions)) {
|
|
164
|
+
const a = asRecord(araw);
|
|
165
|
+
const at = a ? asString(a.action_type) : null;
|
|
166
|
+
if (!at)
|
|
167
|
+
continue;
|
|
168
|
+
counter.set(at, (counter.get(at) ?? 0) + 1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const [iterId, counter] of actionCounts) {
|
|
173
|
+
const rows = Array.from(counter.entries())
|
|
174
|
+
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
|
|
175
|
+
.slice(0, 5)
|
|
176
|
+
.map(([action_type, count]) => ({ action_type, count }));
|
|
177
|
+
byIteration.get(iterId).top_actions = rows;
|
|
178
|
+
}
|
|
179
|
+
// Keep every declared iteration as a slice so the consumer sees the full
|
|
180
|
+
// matrix at stable size. Iterations with zero surviving rows still appear
|
|
181
|
+
// with `participant_count: 0` — useful for "matched X / Y" framing.
|
|
182
|
+
const slices = order.map((o) => byIteration.get(o.id));
|
|
183
|
+
return {
|
|
184
|
+
study: studyHeader(filtered),
|
|
185
|
+
slices,
|
|
186
|
+
totals_unfiltered: filtered.totals_unfiltered,
|
|
187
|
+
warnings: filtered.warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* `--group-by frame` — one slice per Frame that had a surviving interaction.
|
|
192
|
+
* Interactive only — the surface (T5) errors before reaching here when the
|
|
193
|
+
* study isn't interactive. Includes a synthetic `_unmatched` bucket when
|
|
194
|
+
* `--include-unmatched` was set and null-frame_version_id rows survived.
|
|
195
|
+
*
|
|
196
|
+
* Returns a bare array (no wrapper) — callers attach totals_unfiltered.
|
|
197
|
+
*/
|
|
198
|
+
export function buildStudyResultsPerFrame(filtered) {
|
|
199
|
+
const byFrame = new Map();
|
|
200
|
+
const seenAliasesPerFrame = new Map();
|
|
201
|
+
const ensureSlice = (frameId, label) => {
|
|
202
|
+
let slice = byFrame.get(frameId);
|
|
203
|
+
if (!slice) {
|
|
204
|
+
slice = {
|
|
205
|
+
frame_id: frameId,
|
|
206
|
+
frame_label: label,
|
|
207
|
+
interaction_count: 0,
|
|
208
|
+
sentiment_histogram: {},
|
|
209
|
+
sample_comments: [],
|
|
210
|
+
participant_aliases: [],
|
|
211
|
+
};
|
|
212
|
+
byFrame.set(frameId, slice);
|
|
213
|
+
seenAliasesPerFrame.set(frameId, new Set());
|
|
214
|
+
}
|
|
215
|
+
else if (slice.frame_label === null && label !== null) {
|
|
216
|
+
slice.frame_label = label;
|
|
217
|
+
}
|
|
218
|
+
return slice;
|
|
219
|
+
};
|
|
220
|
+
for (const p of filtered.participants) {
|
|
221
|
+
for (const raw of asArray(p.interactions)) {
|
|
222
|
+
const ix = asRecord(raw);
|
|
223
|
+
if (!ix)
|
|
224
|
+
continue;
|
|
225
|
+
const fvId = asString(ix.frame_version_id);
|
|
226
|
+
let frameId;
|
|
227
|
+
let label;
|
|
228
|
+
if (!fvId) {
|
|
229
|
+
frameId = UNMATCHED_BUCKET;
|
|
230
|
+
label = null;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const lookup = filtered.frameVersionLookup.get(fvId);
|
|
234
|
+
if (lookup) {
|
|
235
|
+
frameId = lookup.frame_id;
|
|
236
|
+
label = lookup.frame_label;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Defensive: a surviving fv_id with no lookup entry (when no
|
|
240
|
+
// --frame was passed but a caller still asks for --group-by
|
|
241
|
+
// frame). Bucket by frame_version_id so grouping stays meaningful.
|
|
242
|
+
frameId = fvId;
|
|
243
|
+
label = null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const slice = ensureSlice(frameId, label);
|
|
247
|
+
slice.interaction_count += 1;
|
|
248
|
+
pushSentiment(slice.sentiment_histogram, readSentimentLabel(ix));
|
|
249
|
+
collectComment(slice.sample_comments, ix);
|
|
250
|
+
collectParticipantAlias(slice.participant_aliases, seenAliasesPerFrame.get(frameId), p);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Stable ordering: named frames first (alphabetical by label, then frame_id
|
|
254
|
+
// for label-less), then `_unmatched` at the end.
|
|
255
|
+
return Array.from(byFrame.values()).sort((a, b) => {
|
|
256
|
+
if (a.frame_id === UNMATCHED_BUCKET)
|
|
257
|
+
return 1;
|
|
258
|
+
if (b.frame_id === UNMATCHED_BUCKET)
|
|
259
|
+
return -1;
|
|
260
|
+
const al = a.frame_label ?? "";
|
|
261
|
+
const bl = b.frame_label ?? "";
|
|
262
|
+
if (al !== bl)
|
|
263
|
+
return al.localeCompare(bl);
|
|
264
|
+
return a.frame_id.localeCompare(b.frame_id);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* `--group-by segment` — media studies (video / audio / text / document).
|
|
269
|
+
* Groups by `actions[0].data.segment_index`, falling back to `segment_label`
|
|
270
|
+
* when the index isn't present.
|
|
271
|
+
*/
|
|
272
|
+
export function buildStudyResultsPerSegment(filtered) {
|
|
273
|
+
const byKey = new Map();
|
|
274
|
+
for (const p of filtered.participants) {
|
|
275
|
+
for (const raw of asArray(p.interactions)) {
|
|
276
|
+
const ix = asRecord(raw);
|
|
277
|
+
if (!ix)
|
|
278
|
+
continue;
|
|
279
|
+
const data = firstActionData(ix);
|
|
280
|
+
const idx = typeof data.segment_index === "number" ? data.segment_index : null;
|
|
281
|
+
const label = asString(data.segment_label);
|
|
282
|
+
if (idx === null && label === null)
|
|
283
|
+
continue;
|
|
284
|
+
const key = `${idx ?? "_"}|${label ?? "_"}`;
|
|
285
|
+
let slice = byKey.get(key);
|
|
286
|
+
if (!slice) {
|
|
287
|
+
slice = {
|
|
288
|
+
segment_index: idx,
|
|
289
|
+
segment_label: label,
|
|
290
|
+
interaction_count: 0,
|
|
291
|
+
sentiment_histogram: {},
|
|
292
|
+
engagement_histogram: {},
|
|
293
|
+
sample_comments: [],
|
|
294
|
+
};
|
|
295
|
+
byKey.set(key, slice);
|
|
296
|
+
}
|
|
297
|
+
slice.interaction_count += 1;
|
|
298
|
+
pushSentiment(slice.sentiment_histogram, readSentimentLabel(ix));
|
|
299
|
+
pushEngagement(slice.engagement_histogram, readEngagement(ix));
|
|
300
|
+
collectComment(slice.sample_comments, ix);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return Array.from(byKey.values()).sort((a, b) => {
|
|
304
|
+
if (a.segment_index === null && b.segment_index === null) {
|
|
305
|
+
return (a.segment_label ?? "").localeCompare(b.segment_label ?? "");
|
|
306
|
+
}
|
|
307
|
+
if (a.segment_index === null)
|
|
308
|
+
return 1;
|
|
309
|
+
if (b.segment_index === null)
|
|
310
|
+
return -1;
|
|
311
|
+
if (a.segment_index !== b.segment_index) {
|
|
312
|
+
return a.segment_index - b.segment_index;
|
|
313
|
+
}
|
|
314
|
+
return (a.segment_label ?? "").localeCompare(b.segment_label ?? "");
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* `--group-by turn` — chat studies. Groups by `actions[0].data.turn_index`
|
|
319
|
+
* and surfaces both a count of bot-failure stubs (`bot_reply.failure`
|
|
320
|
+
* populated) and up to 5 sample bot replies per turn.
|
|
321
|
+
*/
|
|
322
|
+
export function buildStudyResultsPerTurn(filtered) {
|
|
323
|
+
const byTurn = new Map();
|
|
324
|
+
for (const p of filtered.participants) {
|
|
325
|
+
for (const raw of asArray(p.interactions)) {
|
|
326
|
+
const ix = asRecord(raw);
|
|
327
|
+
if (!ix)
|
|
328
|
+
continue;
|
|
329
|
+
const data = firstActionData(ix);
|
|
330
|
+
if (typeof data.turn_index !== "number")
|
|
331
|
+
continue;
|
|
332
|
+
const turn = data.turn_index;
|
|
333
|
+
let slice = byTurn.get(turn);
|
|
334
|
+
if (!slice) {
|
|
335
|
+
slice = {
|
|
336
|
+
turn_index: turn,
|
|
337
|
+
interaction_count: 0,
|
|
338
|
+
sentiment_histogram: {},
|
|
339
|
+
sample_replies: [],
|
|
340
|
+
failures: 0,
|
|
341
|
+
};
|
|
342
|
+
byTurn.set(turn, slice);
|
|
343
|
+
}
|
|
344
|
+
slice.interaction_count += 1;
|
|
345
|
+
pushSentiment(slice.sentiment_histogram, readSentimentLabel(ix));
|
|
346
|
+
const botReply = asRecord(ix.bot_reply);
|
|
347
|
+
if (botReply) {
|
|
348
|
+
if (asRecord(botReply.failure)) {
|
|
349
|
+
slice.failures += 1;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const text = asString(botReply.text);
|
|
353
|
+
if (text && slice.sample_replies.length < SAMPLE_REPLY_CAP) {
|
|
354
|
+
slice.sample_replies.push(truncate(text, SAMPLE_REPLY_MAX_LEN));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return Array.from(byTurn.values()).sort((a, b) => a.turn_index - b.turn_index);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* `--group-by assignment` — one slice per study assignment, with each
|
|
364
|
+
* assignment's `step_completion[]` (from the study payload) attached so the
|
|
365
|
+
* caller can see pass / inconclusive / fail rollups inline.
|
|
366
|
+
*/
|
|
367
|
+
export function buildStudyResultsPerAssignment(filtered) {
|
|
368
|
+
const assignments = asArray(filtered.study.assignments);
|
|
369
|
+
const order = [];
|
|
370
|
+
const stepCompletionById = new Map();
|
|
371
|
+
const nameById = new Map();
|
|
372
|
+
for (const raw of assignments) {
|
|
373
|
+
const a = asRecord(raw);
|
|
374
|
+
if (!a)
|
|
375
|
+
continue;
|
|
376
|
+
const id = asString(a.id);
|
|
377
|
+
if (!id)
|
|
378
|
+
continue;
|
|
379
|
+
const name = asString(a.name);
|
|
380
|
+
const sc = asArray(a.step_completion);
|
|
381
|
+
order.push({ id, name, step_completion: sc });
|
|
382
|
+
stepCompletionById.set(id, sc);
|
|
383
|
+
nameById.set(id, name);
|
|
384
|
+
}
|
|
385
|
+
const byAssignment = new Map();
|
|
386
|
+
const ensure = (id) => {
|
|
387
|
+
let slice = byAssignment.get(id);
|
|
388
|
+
if (!slice) {
|
|
389
|
+
slice = {
|
|
390
|
+
assignment_id: id,
|
|
391
|
+
assignment_name: nameById.get(id) ?? null,
|
|
392
|
+
interaction_count: 0,
|
|
393
|
+
sentiment_histogram: {},
|
|
394
|
+
step_completion: stepCompletionById.get(id) ?? [],
|
|
395
|
+
};
|
|
396
|
+
byAssignment.set(id, slice);
|
|
397
|
+
}
|
|
398
|
+
return slice;
|
|
399
|
+
};
|
|
400
|
+
// Seed every declared assignment so the caller sees the full matrix even
|
|
401
|
+
// when a filter wipes some out (interaction_count: 0, but step_completion
|
|
402
|
+
// still visible for context). This matches the per-iteration convention.
|
|
403
|
+
for (const o of order)
|
|
404
|
+
ensure(o.id);
|
|
405
|
+
for (const p of filtered.participants) {
|
|
406
|
+
for (const raw of asArray(p.interactions)) {
|
|
407
|
+
const ix = asRecord(raw);
|
|
408
|
+
if (!ix)
|
|
409
|
+
continue;
|
|
410
|
+
const aid = asString(ix.assignment_id);
|
|
411
|
+
if (!aid)
|
|
412
|
+
continue;
|
|
413
|
+
const slice = ensure(aid);
|
|
414
|
+
slice.interaction_count += 1;
|
|
415
|
+
pushSentiment(slice.sentiment_histogram, readSentimentLabel(ix));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const ordered = [];
|
|
419
|
+
const consumed = new Set();
|
|
420
|
+
for (const o of order) {
|
|
421
|
+
const slice = byAssignment.get(o.id);
|
|
422
|
+
if (slice) {
|
|
423
|
+
ordered.push(slice);
|
|
424
|
+
consumed.add(o.id);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const [id, slice] of byAssignment) {
|
|
428
|
+
if (!consumed.has(id))
|
|
429
|
+
ordered.push(slice);
|
|
430
|
+
}
|
|
431
|
+
return ordered;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* `--group-by step` — one slice per `(assignment, step_id)` pair with verdict
|
|
435
|
+
* totals (re-derived from surviving participants, NOT the pre-computed
|
|
436
|
+
* step_completion) and per-participant verdict rows inline.
|
|
437
|
+
*
|
|
438
|
+
* Re-deriving totals matters when filters are applied: e.g. a caller asking
|
|
439
|
+
* for `--iteration B --group-by step` wants verdict counts for iteration B
|
|
440
|
+
* only, not the study-wide rollup.
|
|
441
|
+
*/
|
|
442
|
+
export function buildStudyResultsPerStep(filtered) {
|
|
443
|
+
const assignmentNameById = new Map();
|
|
444
|
+
const stepNameByKey = new Map();
|
|
445
|
+
for (const raw of asArray(filtered.study.assignments)) {
|
|
446
|
+
const a = asRecord(raw);
|
|
447
|
+
if (!a)
|
|
448
|
+
continue;
|
|
449
|
+
const aid = asString(a.id);
|
|
450
|
+
if (!aid)
|
|
451
|
+
continue;
|
|
452
|
+
assignmentNameById.set(aid, asString(a.name));
|
|
453
|
+
for (const sraw of asArray(a.steps)) {
|
|
454
|
+
const s = asRecord(sraw);
|
|
455
|
+
if (!s)
|
|
456
|
+
continue;
|
|
457
|
+
const sid = asString(s.id);
|
|
458
|
+
if (!sid)
|
|
459
|
+
continue;
|
|
460
|
+
stepNameByKey.set(`${aid}|${sid}`, asString(s.name));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const byKey = new Map();
|
|
464
|
+
const ensureSlice = (aid, sid, fallbackStepName) => {
|
|
465
|
+
const key = `${aid}|${sid}`;
|
|
466
|
+
let slice = byKey.get(key);
|
|
467
|
+
if (!slice) {
|
|
468
|
+
slice = {
|
|
469
|
+
assignment_id: aid,
|
|
470
|
+
assignment_name: assignmentNameById.get(aid) ?? null,
|
|
471
|
+
step_id: sid,
|
|
472
|
+
step_name: stepNameByKey.get(key) ?? fallbackStepName,
|
|
473
|
+
total: 0,
|
|
474
|
+
passed: 0,
|
|
475
|
+
inconclusive: 0,
|
|
476
|
+
failed: 0,
|
|
477
|
+
rate: 0,
|
|
478
|
+
participant_verdicts: [],
|
|
479
|
+
};
|
|
480
|
+
byKey.set(key, slice);
|
|
481
|
+
}
|
|
482
|
+
return slice;
|
|
483
|
+
};
|
|
484
|
+
// Seed slices from the declared study so steps with zero surviving
|
|
485
|
+
// verdicts still surface — gives "0/0 passed" rather than missing rows.
|
|
486
|
+
for (const raw of asArray(filtered.study.assignments)) {
|
|
487
|
+
const a = asRecord(raw);
|
|
488
|
+
const aid = a ? asString(a.id) : null;
|
|
489
|
+
if (!aid)
|
|
490
|
+
continue;
|
|
491
|
+
for (const sraw of asArray(a?.steps)) {
|
|
492
|
+
const s = asRecord(sraw);
|
|
493
|
+
const sid = s ? asString(s.id) : null;
|
|
494
|
+
if (!sid)
|
|
495
|
+
continue;
|
|
496
|
+
ensureSlice(aid, sid, s ? asString(s.name) : null);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const p of filtered.participants) {
|
|
500
|
+
const alias = participantAlias(p);
|
|
501
|
+
for (const paRaw of asArray(p.participant_assignments)) {
|
|
502
|
+
const pa = asRecord(paRaw);
|
|
503
|
+
if (!pa)
|
|
504
|
+
continue;
|
|
505
|
+
const aid = asString(pa.assignment_id);
|
|
506
|
+
if (!aid)
|
|
507
|
+
continue;
|
|
508
|
+
for (const srRaw of asArray(pa.step_results)) {
|
|
509
|
+
const sr = asRecord(srRaw);
|
|
510
|
+
if (!sr)
|
|
511
|
+
continue;
|
|
512
|
+
const sid = asString(sr.step_id);
|
|
513
|
+
if (!sid)
|
|
514
|
+
continue;
|
|
515
|
+
const slice = ensureSlice(aid, sid, asString(sr.name));
|
|
516
|
+
const verdict = asString(sr.verdict);
|
|
517
|
+
slice.total += 1;
|
|
518
|
+
if (verdict === "passed" || verdict === "pass")
|
|
519
|
+
slice.passed += 1;
|
|
520
|
+
else if (verdict === "inconclusive")
|
|
521
|
+
slice.inconclusive += 1;
|
|
522
|
+
else if (verdict === "failed" || verdict === "fail")
|
|
523
|
+
slice.failed += 1;
|
|
524
|
+
const evidence = [];
|
|
525
|
+
for (const eid of asArray(sr.evidence_interaction_ids)) {
|
|
526
|
+
const eidStr = asString(eid);
|
|
527
|
+
if (eidStr)
|
|
528
|
+
evidence.push(eidStr);
|
|
529
|
+
}
|
|
530
|
+
slice.participant_verdicts.push({
|
|
531
|
+
participant_alias: alias,
|
|
532
|
+
verdict,
|
|
533
|
+
reason: asString(sr.reason),
|
|
534
|
+
evidence_interaction_ids: evidence,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const declaredOrder = [];
|
|
540
|
+
for (const raw of asArray(filtered.study.assignments)) {
|
|
541
|
+
const a = asRecord(raw);
|
|
542
|
+
if (!a)
|
|
543
|
+
continue;
|
|
544
|
+
const aid = asString(a.id);
|
|
545
|
+
if (!aid)
|
|
546
|
+
continue;
|
|
547
|
+
for (const sraw of asArray(a.steps)) {
|
|
548
|
+
const s = asRecord(sraw);
|
|
549
|
+
const sid = s ? asString(s.id) : null;
|
|
550
|
+
if (sid)
|
|
551
|
+
declaredOrder.push(`${aid}|${sid}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const ordered = [];
|
|
555
|
+
const consumed = new Set();
|
|
556
|
+
for (const key of declaredOrder) {
|
|
557
|
+
const slice = byKey.get(key);
|
|
558
|
+
if (slice) {
|
|
559
|
+
slice.rate = slice.total > 0
|
|
560
|
+
? Math.round((slice.passed / slice.total) * 100) / 100
|
|
561
|
+
: 0;
|
|
562
|
+
slice.participant_verdicts.sort((a, b) => (a.participant_alias ?? "").localeCompare(b.participant_alias ?? ""));
|
|
563
|
+
ordered.push(slice);
|
|
564
|
+
consumed.add(key);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const [key, slice] of byKey) {
|
|
568
|
+
if (consumed.has(key))
|
|
569
|
+
continue;
|
|
570
|
+
slice.rate = slice.total > 0
|
|
571
|
+
? Math.round((slice.passed / slice.total) * 100) / 100
|
|
572
|
+
: 0;
|
|
573
|
+
slice.participant_verdicts.sort((a, b) => (a.participant_alias ?? "").localeCompare(b.participant_alias ?? ""));
|
|
574
|
+
ordered.push(slice);
|
|
575
|
+
}
|
|
576
|
+
return ordered;
|
|
577
|
+
}
|