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