@ishlabs/cli 0.19.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/ask.js +26 -2
- package/dist/commands/config.js +9 -1
- package/dist/commands/docs.js +6 -7
- package/dist/commands/person.js +123 -9
- package/dist/commands/secret.js +25 -2
- package/dist/commands/source.d.ts +1 -1
- package/dist/commands/source.js +10 -6
- package/dist/commands/study.js +269 -13
- package/dist/commands/workspace.js +41 -6
- package/dist/index.js +227 -44
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +25 -4
- package/dist/lib/auth.js +22 -4
- package/dist/lib/baggage.d.ts +15 -6
- package/dist/lib/baggage.js +14 -8
- package/dist/lib/command-helpers.d.ts +1 -0
- package/dist/lib/command-helpers.js +79 -7
- package/dist/lib/docs.js +400 -21
- package/dist/lib/output.d.ts +18 -0
- package/dist/lib/output.js +278 -18
- package/dist/lib/profile-sources.js +18 -0
- package/dist/lib/skill-content.js +78 -2
- 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/dist/upgrade.js +9 -2
- package/package.json +1 -1
|
@@ -6,6 +6,19 @@
|
|
|
6
6
|
* (person, interactions[], participant_summary, interview_answers, …) that
|
|
7
7
|
* used to be embedded under `study.iterations[*].participants[*]` on the
|
|
8
8
|
* legacy `GET /studies/{id}` response.
|
|
9
|
+
*
|
|
10
|
+
* Audit (study-results-slice plan, T4): the flat endpoint already returns
|
|
11
|
+
* everything the new `ish study results --frame/--segment/--step/...` filter
|
|
12
|
+
* pipeline needs in a single round-trip — no per-participant fan-out:
|
|
13
|
+
* - `interactions[]` (modality-discriminated via `ParticipantWithAttributesPublicResponse`)
|
|
14
|
+
* - `participant_assignments[].step_results[]` with `{step_id, name,
|
|
15
|
+
* description, verdict, reason, evidence_interaction_ids[]}`, hydrated
|
|
16
|
+
* by `attach_participant_step_results_flat` in the study repository
|
|
17
|
+
* before serialisation (`ish-backend/app/api/study/repository.py:315`)
|
|
18
|
+
* - `participant_summary`, `interview_answers`
|
|
19
|
+
* If a future filter ever needs `conversation_id` on each interaction (for
|
|
20
|
+
* `--group-by conversation`), that's a backend-side addition on
|
|
21
|
+
* `_InteractionResponseBase`, not a CLI change.
|
|
9
22
|
*/
|
|
10
23
|
export async function fetchStudyParticipants(client, studyId, opts) {
|
|
11
24
|
return await client.get(`/studies/${studyId}/participants`, undefined, opts);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure filter pipeline for `ish study results`.
|
|
3
|
+
*
|
|
4
|
+
* Input : the raw `GET /studies/{id}` payload, the raw
|
|
5
|
+
* `GET /studies/{id}/participants` payload, the raw
|
|
6
|
+
* `GET /studies/{id}/frames` payload (or [] when --frame wasn't
|
|
7
|
+
* passed), and a `ResultsFilters` struct from the command surface.
|
|
8
|
+
* Output : a `FilteredResults` struct — the trimmed participant graph,
|
|
9
|
+
* pre-filter counts on `totals_unfiltered`, and a `warnings[]`
|
|
10
|
+
* list of modality-mismatch notes for the surface to surface on
|
|
11
|
+
* stderr.
|
|
12
|
+
*
|
|
13
|
+
* Has no IO and no console side-effects — the caller (study results action)
|
|
14
|
+
* owns network calls and stderr; we just compute. That keeps the function
|
|
15
|
+
* trivially unit-testable and lets the projection builders (T3) consume the
|
|
16
|
+
* same shape without re-walking the graph.
|
|
17
|
+
*
|
|
18
|
+
* Defensive null handling is the load-bearing piece. See the plan's
|
|
19
|
+
* "Defensive handling of nullable fields" section — read it before editing
|
|
20
|
+
* any predicate.
|
|
21
|
+
*/
|
|
22
|
+
export interface ResultsFilters {
|
|
23
|
+
/** Frame name (case-insensitive substring), Frame UUID, frame alias `f-...`,
|
|
24
|
+
* or a `frame_version_id` UUID. Resolved against the study's frames list. */
|
|
25
|
+
frame?: string;
|
|
26
|
+
/** Segment index (parseable int) OR a substring matched against
|
|
27
|
+
* `actions[0].data.segment_label` on each interaction. */
|
|
28
|
+
segment?: string;
|
|
29
|
+
/** Chat turn index — matched against `actions[0].data.turn_index`. */
|
|
30
|
+
turn?: number;
|
|
31
|
+
/** participant_pair side — matched against the parent assignment's `side`. */
|
|
32
|
+
side?: "a" | "b";
|
|
33
|
+
/** Assignment UUID, OR a substring matched against
|
|
34
|
+
* `study.assignments[].name`. */
|
|
35
|
+
assignment?: string;
|
|
36
|
+
/** Step id OR a case-insensitive substring against step `name`. Walks
|
|
37
|
+
* `participant_assignments[].step_results[]`. */
|
|
38
|
+
step?: string;
|
|
39
|
+
/** Comma-or-repeat list of sentiment labels (case-insensitive). */
|
|
40
|
+
sentiment?: string[];
|
|
41
|
+
/** Actor field — case-insensitive match against `interaction.actor`. */
|
|
42
|
+
actor?: "ai" | "human" | "user";
|
|
43
|
+
/** Iteration UUID or `label`. */
|
|
44
|
+
iteration?: string;
|
|
45
|
+
/** Participant UUID or alias (`pt-...`). */
|
|
46
|
+
participant?: string;
|
|
47
|
+
/** When --frame is set, keep interactions with null frame_version_id
|
|
48
|
+
* under a synthetic `_unmatched` bucket instead of dropping them. */
|
|
49
|
+
includeUnmatched?: boolean;
|
|
50
|
+
/** Pair with --step: also drop interactions whose id is not in any
|
|
51
|
+
* surviving `step_results[].evidence_interaction_ids[]`. */
|
|
52
|
+
includeEvidence?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface FilteredResults {
|
|
55
|
+
/** Shallow copy of the study payload — same shape as the raw response.
|
|
56
|
+
* Participants are NOT embedded here; they're carried alongside on
|
|
57
|
+
* `participants`. */
|
|
58
|
+
study: Record<string, unknown>;
|
|
59
|
+
/** Participants whose interactions[] survived the predicate walk.
|
|
60
|
+
* Empty participants are dropped only when an interaction-level filter
|
|
61
|
+
* was set (preserves the stable schema when the caller just asked
|
|
62
|
+
* "who ran?" without slicing). */
|
|
63
|
+
participants: Record<string, unknown>[];
|
|
64
|
+
/** The frame list returned by the surface, with each frame's
|
|
65
|
+
* `frame_version_ids[]` flattened onto the row for downstream
|
|
66
|
+
* enrichment. Empty when --frame wasn't passed or the modality isn't
|
|
67
|
+
* interactive. */
|
|
68
|
+
frames: Record<string, unknown>[];
|
|
69
|
+
/** Pre-filter participant + interaction counts, so callers can see
|
|
70
|
+
* "matched X / Y". */
|
|
71
|
+
totals_unfiltered: {
|
|
72
|
+
participant_count: number;
|
|
73
|
+
interaction_count: number;
|
|
74
|
+
};
|
|
75
|
+
/** Modality-mismatch notes (e.g. "--segment ignored on interactive").
|
|
76
|
+
* The surface emits these on stderr. */
|
|
77
|
+
warnings: string[];
|
|
78
|
+
/** When --frame was set, the resolved set of frame_version_ids that
|
|
79
|
+
* passed. Used by the projection builders (T3) to enrich surviving
|
|
80
|
+
* interactions with frame_id / frame_label without re-resolving. */
|
|
81
|
+
matchedFrameVersionIds: Set<string>;
|
|
82
|
+
/** Maps frame_version_id → {frame_id, frame_label} for enrichment. */
|
|
83
|
+
frameVersionLookup: Map<string, {
|
|
84
|
+
frame_id: string;
|
|
85
|
+
frame_label: string | null;
|
|
86
|
+
}>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Pure entry point. See file-level comment for input/output contract.
|
|
90
|
+
*/
|
|
91
|
+
export declare function applyResultsFilters(study: Record<string, unknown>, participants: Record<string, unknown>[], rawFrames: Record<string, unknown>[], filters: ResultsFilters): FilteredResults;
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure filter pipeline for `ish study results`.
|
|
3
|
+
*
|
|
4
|
+
* Input : the raw `GET /studies/{id}` payload, the raw
|
|
5
|
+
* `GET /studies/{id}/participants` payload, the raw
|
|
6
|
+
* `GET /studies/{id}/frames` payload (or [] when --frame wasn't
|
|
7
|
+
* passed), and a `ResultsFilters` struct from the command surface.
|
|
8
|
+
* Output : a `FilteredResults` struct — the trimmed participant graph,
|
|
9
|
+
* pre-filter counts on `totals_unfiltered`, and a `warnings[]`
|
|
10
|
+
* list of modality-mismatch notes for the surface to surface on
|
|
11
|
+
* stderr.
|
|
12
|
+
*
|
|
13
|
+
* Has no IO and no console side-effects — the caller (study results action)
|
|
14
|
+
* owns network calls and stderr; we just compute. That keeps the function
|
|
15
|
+
* trivially unit-testable and lets the projection builders (T3) consume the
|
|
16
|
+
* same shape without re-walking the graph.
|
|
17
|
+
*
|
|
18
|
+
* Defensive null handling is the load-bearing piece. See the plan's
|
|
19
|
+
* "Defensive handling of nullable fields" section — read it before editing
|
|
20
|
+
* any predicate.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveId } from "./alias-store.js";
|
|
23
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
function asRecord(v) {
|
|
25
|
+
return v && typeof v === "object" && !Array.isArray(v)
|
|
26
|
+
? v
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
function asArray(v) {
|
|
30
|
+
return Array.isArray(v) ? v : [];
|
|
31
|
+
}
|
|
32
|
+
function asString(v) {
|
|
33
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
34
|
+
}
|
|
35
|
+
function normaliseFrames(rawFrames) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const row of rawFrames) {
|
|
38
|
+
const r = asRecord(row);
|
|
39
|
+
if (!r)
|
|
40
|
+
continue;
|
|
41
|
+
const id = asString(r.id);
|
|
42
|
+
if (!id)
|
|
43
|
+
continue;
|
|
44
|
+
const versions = [];
|
|
45
|
+
for (const v of asArray(r.versions)) {
|
|
46
|
+
const vr = asRecord(v);
|
|
47
|
+
const vid = vr ? asString(vr.id) : null;
|
|
48
|
+
if (vid)
|
|
49
|
+
versions.push({ id: vid });
|
|
50
|
+
}
|
|
51
|
+
out.push({ id, name: asString(r.name), versions });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function normaliseAssignments(rawAssignments) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const row of rawAssignments) {
|
|
58
|
+
const r = asRecord(row);
|
|
59
|
+
if (!r)
|
|
60
|
+
continue;
|
|
61
|
+
const id = asString(r.id);
|
|
62
|
+
if (!id)
|
|
63
|
+
continue;
|
|
64
|
+
const side = r.side === "a" || r.side === "b" ? r.side : null;
|
|
65
|
+
out.push({ id, name: asString(r.name), side });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function normaliseIterations(rawIterations) {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const row of rawIterations) {
|
|
72
|
+
const r = asRecord(row);
|
|
73
|
+
if (!r)
|
|
74
|
+
continue;
|
|
75
|
+
const id = asString(r.id);
|
|
76
|
+
if (!id)
|
|
77
|
+
continue;
|
|
78
|
+
out.push({ id, label: asString(r.label) });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** Throw a ValidationError-tagged Error (the wrapper maps it to exit 2). */
|
|
83
|
+
function validationError(message) {
|
|
84
|
+
const err = new Error(message);
|
|
85
|
+
err.name = "ValidationError";
|
|
86
|
+
return err;
|
|
87
|
+
}
|
|
88
|
+
/** Resolve `--frame <ref>` into a set of frame_version_ids.
|
|
89
|
+
*
|
|
90
|
+
* Branches on shape:
|
|
91
|
+
* - `f-<hex>` → frame alias, resolves to a Frame UUID via alias-store
|
|
92
|
+
* - full UUID → first try Frame.id; if no match, treat as frame_version_id
|
|
93
|
+
* - anything else → case-insensitive substring against `frame.name`
|
|
94
|
+
*
|
|
95
|
+
* Ambiguous substring matches (>1 frame) throw with the candidate list.
|
|
96
|
+
* No matches at all throw a not-found ValidationError listing alternatives.
|
|
97
|
+
*/
|
|
98
|
+
function resolveFrameRef(ref, frames) {
|
|
99
|
+
const matched = new Set();
|
|
100
|
+
const lookup = new Map();
|
|
101
|
+
const indexFrame = (frame) => {
|
|
102
|
+
for (const v of frame.versions) {
|
|
103
|
+
matched.add(v.id);
|
|
104
|
+
lookup.set(v.id, { frame_id: frame.id, frame_label: frame.name });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// Build a frame_version_id → frame lookup once; reused on the
|
|
108
|
+
// "is this a frame_version_id?" path.
|
|
109
|
+
const fvToFrame = new Map();
|
|
110
|
+
for (const f of frames) {
|
|
111
|
+
for (const v of f.versions)
|
|
112
|
+
fvToFrame.set(v.id, f);
|
|
113
|
+
}
|
|
114
|
+
// 1. Frame alias (f-…) — resolve to a Frame UUID via alias-store.
|
|
115
|
+
if (/^f-[0-9a-f]{3,}$/i.test(ref)) {
|
|
116
|
+
const resolved = resolveId(ref); // throws not_found if alias is unknown
|
|
117
|
+
const frame = frames.find((f) => f.id === resolved);
|
|
118
|
+
if (!frame) {
|
|
119
|
+
throw validationError(`Frame alias "${ref}" resolved to ${resolved}, but no frame with that id exists on this study.`);
|
|
120
|
+
}
|
|
121
|
+
indexFrame(frame);
|
|
122
|
+
return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
|
|
123
|
+
}
|
|
124
|
+
// 2. Full UUID — try Frame.id first, then frame_version_id.
|
|
125
|
+
if (UUID_RE.test(ref)) {
|
|
126
|
+
const frame = frames.find((f) => f.id === ref);
|
|
127
|
+
if (frame) {
|
|
128
|
+
indexFrame(frame);
|
|
129
|
+
return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
|
|
130
|
+
}
|
|
131
|
+
const parentByVersion = fvToFrame.get(ref);
|
|
132
|
+
if (parentByVersion) {
|
|
133
|
+
// Treat as frame_version_id — match only this single version.
|
|
134
|
+
matched.add(ref);
|
|
135
|
+
lookup.set(ref, { frame_id: parentByVersion.id, frame_label: parentByVersion.name });
|
|
136
|
+
return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
|
|
137
|
+
}
|
|
138
|
+
throw validationError(`No frame or frame_version matches "${ref}" on this study. ` +
|
|
139
|
+
`Run \`ish study results <id> --get frames\` (or check the frames endpoint) to see the list.`);
|
|
140
|
+
}
|
|
141
|
+
// 3. Case-insensitive substring against frame.name. The plan calls
|
|
142
|
+
// for the ambiguous-match error to list candidates explicitly.
|
|
143
|
+
const needle = ref.toLowerCase();
|
|
144
|
+
const candidates = frames.filter((f) => f.name && f.name.toLowerCase().includes(needle));
|
|
145
|
+
if (candidates.length === 0) {
|
|
146
|
+
const available = frames
|
|
147
|
+
.map((f) => f.name)
|
|
148
|
+
.filter((n) => typeof n === "string")
|
|
149
|
+
.slice(0, 10);
|
|
150
|
+
const hint = available.length > 0
|
|
151
|
+
? ` Available frames: ${available.join(", ")}.`
|
|
152
|
+
: " This study has no named frames yet.";
|
|
153
|
+
throw validationError(`--frame "${ref}" matched no frames on this study.${hint}`);
|
|
154
|
+
}
|
|
155
|
+
if (candidates.length > 1) {
|
|
156
|
+
const names = candidates.map((c) => c.name).filter((n) => !!n);
|
|
157
|
+
throw validationError(`--frame "${ref}" is ambiguous — matched ${candidates.length} frames: ${names.join(", ")}. ` +
|
|
158
|
+
`Use a more specific substring, a full Frame UUID, or an \`f-…\` alias.`);
|
|
159
|
+
}
|
|
160
|
+
indexFrame(candidates[0]);
|
|
161
|
+
return { matchedFrameVersionIds: matched, frameVersionLookup: lookup };
|
|
162
|
+
}
|
|
163
|
+
/** Resolve `--assignment <ref>` into a set of assignment_ids.
|
|
164
|
+
*
|
|
165
|
+
* UUID → exact match. Otherwise case-insensitive substring against name.
|
|
166
|
+
* Unlike --frame, multiple matches are NOT ambiguous — the user might
|
|
167
|
+
* legitimately want every "Sign up *" assignment; we just OR them.
|
|
168
|
+
*/
|
|
169
|
+
function resolveAssignmentRef(ref, assignments) {
|
|
170
|
+
const out = new Set();
|
|
171
|
+
if (UUID_RE.test(ref)) {
|
|
172
|
+
const m = assignments.find((a) => a.id === ref);
|
|
173
|
+
if (!m) {
|
|
174
|
+
throw validationError(`No assignment matches "${ref}" on this study.`);
|
|
175
|
+
}
|
|
176
|
+
out.add(m.id);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
const needle = ref.toLowerCase();
|
|
180
|
+
for (const a of assignments) {
|
|
181
|
+
if (a.name && a.name.toLowerCase().includes(needle))
|
|
182
|
+
out.add(a.id);
|
|
183
|
+
}
|
|
184
|
+
if (out.size === 0) {
|
|
185
|
+
const names = assignments
|
|
186
|
+
.map((a) => a.name)
|
|
187
|
+
.filter((n) => !!n)
|
|
188
|
+
.slice(0, 10);
|
|
189
|
+
const hint = names.length > 0 ? ` Available: ${names.join(", ")}.` : "";
|
|
190
|
+
throw validationError(`--assignment "${ref}" matched no assignments on this study.${hint}`);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** Resolve `--iteration <ref>` to a single iteration_id, or null if no
|
|
195
|
+
* match (caller errors). UUID-exact or label-exact. */
|
|
196
|
+
function resolveIterationRef(ref, iterations) {
|
|
197
|
+
if (UUID_RE.test(ref)) {
|
|
198
|
+
const m = iterations.find((i) => i.id === ref);
|
|
199
|
+
if (!m) {
|
|
200
|
+
throw validationError(`No iteration matches "${ref}" on this study.`);
|
|
201
|
+
}
|
|
202
|
+
return m.id;
|
|
203
|
+
}
|
|
204
|
+
// Labels are uppercase A-Z by backend constraint; do a case-insensitive
|
|
205
|
+
// exact compare so `--iteration a` works as well as `--iteration A`.
|
|
206
|
+
const upper = ref.toUpperCase();
|
|
207
|
+
const m = iterations.find((i) => (i.label ?? "").toUpperCase() === upper);
|
|
208
|
+
if (!m) {
|
|
209
|
+
const labels = iterations
|
|
210
|
+
.map((i) => i.label)
|
|
211
|
+
.filter((n) => !!n);
|
|
212
|
+
const hint = labels.length > 0 ? ` Available labels: ${labels.join(", ")}.` : "";
|
|
213
|
+
throw validationError(`--iteration "${ref}" matched no iterations on this study.${hint}`);
|
|
214
|
+
}
|
|
215
|
+
return m.id;
|
|
216
|
+
}
|
|
217
|
+
/** Resolve --participant to a UUID (alias or full UUID, via resolveId). */
|
|
218
|
+
function resolveParticipantRef(ref) {
|
|
219
|
+
// resolveId throws not_found for unknown aliases / invalid IDs.
|
|
220
|
+
return resolveId(ref);
|
|
221
|
+
}
|
|
222
|
+
/** Get the modality of the study. Returns a normalised lowercase string
|
|
223
|
+
* ("interactive" | "chat" | "video" | "audio" | "text" | "image" |
|
|
224
|
+
* "document"), or "unknown" if missing. */
|
|
225
|
+
function getModality(study) {
|
|
226
|
+
const m = asString(study.modality);
|
|
227
|
+
return m ? m.toLowerCase() : "unknown";
|
|
228
|
+
}
|
|
229
|
+
/** True if the study is participant_pair chat (where --side is meaningful). */
|
|
230
|
+
function isParticipantPair(study) {
|
|
231
|
+
if (getModality(study) !== "chat")
|
|
232
|
+
return false;
|
|
233
|
+
// mode_details lives under either `mode_details` on the study or
|
|
234
|
+
// `iteration.details.mode_details` per-iteration. The study-level
|
|
235
|
+
// `pair_mode` field (if present) is the cheap check; otherwise we
|
|
236
|
+
// peek at the assignments — pair studies have side="a"/"b" set.
|
|
237
|
+
const assignments = asArray(study.assignments);
|
|
238
|
+
for (const raw of assignments) {
|
|
239
|
+
const a = asRecord(raw);
|
|
240
|
+
if (a && (a.side === "a" || a.side === "b"))
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
/** Return true if the interaction's parent assignment has the given side. */
|
|
246
|
+
function interactionMatchesSide(interaction, assignmentSideById, wantedSide) {
|
|
247
|
+
const aid = asString(interaction.assignment_id);
|
|
248
|
+
if (!aid)
|
|
249
|
+
return false;
|
|
250
|
+
const side = assignmentSideById.get(aid);
|
|
251
|
+
return side === wantedSide;
|
|
252
|
+
}
|
|
253
|
+
/** Read `actions[0].data` defensively. Returns {} when actions is empty
|
|
254
|
+
* or the first row has no data block. */
|
|
255
|
+
function firstActionData(interaction) {
|
|
256
|
+
const actions = asArray(interaction.actions);
|
|
257
|
+
if (actions.length === 0)
|
|
258
|
+
return {};
|
|
259
|
+
const first = asRecord(actions[0]);
|
|
260
|
+
if (!first)
|
|
261
|
+
return {};
|
|
262
|
+
const data = asRecord(first.data);
|
|
263
|
+
return data ?? {};
|
|
264
|
+
}
|
|
265
|
+
/** Predicate: --segment <ref> matches this interaction.
|
|
266
|
+
*
|
|
267
|
+
* Numeric ref → equality against `data.segment_index`.
|
|
268
|
+
* Non-numeric → case-insensitive substring against `data.segment_label`.
|
|
269
|
+
*/
|
|
270
|
+
function interactionMatchesSegment(interaction, segmentRef) {
|
|
271
|
+
const data = firstActionData(interaction);
|
|
272
|
+
const asInt = Number(segmentRef);
|
|
273
|
+
if (Number.isInteger(asInt) && segmentRef.trim() !== "") {
|
|
274
|
+
return data.segment_index === asInt;
|
|
275
|
+
}
|
|
276
|
+
const label = asString(data.segment_label);
|
|
277
|
+
if (!label)
|
|
278
|
+
return false;
|
|
279
|
+
return label.toLowerCase().includes(segmentRef.toLowerCase());
|
|
280
|
+
}
|
|
281
|
+
/** Predicate: --turn <n> matches this interaction. */
|
|
282
|
+
function interactionMatchesTurn(interaction, turn) {
|
|
283
|
+
const data = firstActionData(interaction);
|
|
284
|
+
return data.turn_index === turn;
|
|
285
|
+
}
|
|
286
|
+
/** Per-participant step filter. Walks `participant_assignments[].step_results[]`,
|
|
287
|
+
* keeps verdict rows whose step_id OR step.name match the user's ref, and
|
|
288
|
+
* optionally returns the evidence_interaction_ids set so the caller can
|
|
289
|
+
* drop interactions outside the evidence (when --include-evidence is set).
|
|
290
|
+
*
|
|
291
|
+
* Returns null when this participant has no surviving step_results — the
|
|
292
|
+
* caller treats that as "no match" for the participant.
|
|
293
|
+
*/
|
|
294
|
+
function filterStepsOnParticipant(participant, stepRef) {
|
|
295
|
+
const assignments = asArray(participant.participant_assignments);
|
|
296
|
+
const needle = stepRef.toLowerCase();
|
|
297
|
+
const filteredAssignments = [];
|
|
298
|
+
const evidence = new Set();
|
|
299
|
+
let kept = 0;
|
|
300
|
+
for (const raw of assignments) {
|
|
301
|
+
const a = asRecord(raw);
|
|
302
|
+
if (!a)
|
|
303
|
+
continue;
|
|
304
|
+
const steps = asArray(a.step_results);
|
|
305
|
+
const survivors = [];
|
|
306
|
+
for (const sraw of steps) {
|
|
307
|
+
const s = asRecord(sraw);
|
|
308
|
+
if (!s)
|
|
309
|
+
continue;
|
|
310
|
+
const sid = asString(s.step_id);
|
|
311
|
+
const sname = asString(s.name);
|
|
312
|
+
const matchesId = sid !== null && sid.toLowerCase() === needle;
|
|
313
|
+
const matchesName = sname !== null && sname.toLowerCase().includes(needle);
|
|
314
|
+
if (!matchesId && !matchesName)
|
|
315
|
+
continue;
|
|
316
|
+
survivors.push(s);
|
|
317
|
+
for (const eid of asArray(s.evidence_interaction_ids)) {
|
|
318
|
+
const eidStr = asString(eid);
|
|
319
|
+
if (eidStr)
|
|
320
|
+
evidence.add(eidStr);
|
|
321
|
+
}
|
|
322
|
+
kept += 1;
|
|
323
|
+
}
|
|
324
|
+
if (survivors.length > 0) {
|
|
325
|
+
filteredAssignments.push({ ...a, step_results: survivors });
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
// Preserve the assignment row but drop step_results entirely so
|
|
329
|
+
// downstream consumers see "this participant ran this assignment
|
|
330
|
+
// but no matching step verdicts" cleanly.
|
|
331
|
+
filteredAssignments.push({ ...a, step_results: [] });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (kept === 0)
|
|
335
|
+
return null;
|
|
336
|
+
return { filteredAssignments, evidenceInteractionIds: evidence };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Pure entry point. See file-level comment for input/output contract.
|
|
340
|
+
*/
|
|
341
|
+
export function applyResultsFilters(study, participants, rawFrames, filters) {
|
|
342
|
+
const warnings = [];
|
|
343
|
+
const modality = getModality(study);
|
|
344
|
+
const frames = normaliseFrames(rawFrames);
|
|
345
|
+
const assignments = normaliseAssignments(asArray(study.assignments));
|
|
346
|
+
const iterations = normaliseIterations(asArray(study.iterations));
|
|
347
|
+
const assignmentSideById = new Map(assignments.map((a) => [a.id, a.side]));
|
|
348
|
+
// --- Resolve refs upfront so we can fail fast with clear errors. ---
|
|
349
|
+
let matchedFrameVersionIds = new Set();
|
|
350
|
+
let frameVersionLookup = new Map();
|
|
351
|
+
if (filters.frame !== undefined) {
|
|
352
|
+
if (modality !== "interactive") {
|
|
353
|
+
warnings.push(`--frame is only meaningful on interactive studies; ignored on modality "${modality}".`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
const resolved = resolveFrameRef(filters.frame, frames);
|
|
357
|
+
matchedFrameVersionIds = resolved.matchedFrameVersionIds;
|
|
358
|
+
frameVersionLookup = resolved.frameVersionLookup;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
let matchedAssignmentIds = null;
|
|
362
|
+
if (filters.assignment !== undefined) {
|
|
363
|
+
matchedAssignmentIds = resolveAssignmentRef(filters.assignment, assignments);
|
|
364
|
+
}
|
|
365
|
+
let matchedIterationId = null;
|
|
366
|
+
if (filters.iteration !== undefined) {
|
|
367
|
+
matchedIterationId = resolveIterationRef(filters.iteration, iterations);
|
|
368
|
+
}
|
|
369
|
+
let matchedParticipantId = null;
|
|
370
|
+
if (filters.participant !== undefined) {
|
|
371
|
+
matchedParticipantId = resolveParticipantRef(filters.participant);
|
|
372
|
+
}
|
|
373
|
+
// --- Modality-mismatch warnings (filter still runs but degrades to no-op). ---
|
|
374
|
+
if (filters.segment !== undefined) {
|
|
375
|
+
if (modality === "interactive" ||
|
|
376
|
+
modality === "chat" ||
|
|
377
|
+
modality === "image" ||
|
|
378
|
+
modality === "unknown") {
|
|
379
|
+
warnings.push(`--segment is only meaningful on video, audio, text, or document studies; ignored on modality "${modality}".`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (filters.turn !== undefined && modality !== "chat") {
|
|
383
|
+
warnings.push(`--turn is only meaningful on chat studies; ignored on modality "${modality}".`);
|
|
384
|
+
}
|
|
385
|
+
if (filters.side !== undefined && !isParticipantPair(study)) {
|
|
386
|
+
warnings.push(`--side is only meaningful on participant_pair chat studies; ignored.`);
|
|
387
|
+
}
|
|
388
|
+
// Normalise sentiment labels to lowercase up-front (case-insensitive).
|
|
389
|
+
const sentimentFilter = filters.sentiment
|
|
390
|
+
? new Set(filters.sentiment.map((s) => s.toLowerCase()))
|
|
391
|
+
: null;
|
|
392
|
+
const actorFilter = filters.actor ? filters.actor.toLowerCase() : null;
|
|
393
|
+
// Set of warnings that downgrade a flag to a no-op for the predicate walk.
|
|
394
|
+
const segmentActive = filters.segment !== undefined && modality !== "interactive"
|
|
395
|
+
&& modality !== "chat" && modality !== "image" && modality !== "unknown";
|
|
396
|
+
const turnActive = filters.turn !== undefined && modality === "chat";
|
|
397
|
+
const sideActive = filters.side !== undefined && isParticipantPair(study);
|
|
398
|
+
const frameActive = filters.frame !== undefined && modality === "interactive";
|
|
399
|
+
// Track whether ANY interaction-level filter is on. Determines whether
|
|
400
|
+
// we drop empty participants or keep them (per plan §3 "empty participants").
|
|
401
|
+
const interactionLevelFilterActive = frameActive ||
|
|
402
|
+
segmentActive ||
|
|
403
|
+
turnActive ||
|
|
404
|
+
sideActive ||
|
|
405
|
+
matchedAssignmentIds !== null ||
|
|
406
|
+
sentimentFilter !== null ||
|
|
407
|
+
actorFilter !== null;
|
|
408
|
+
// --- Pre-filter totals (for the totals_unfiltered envelope field). ---
|
|
409
|
+
let unfilteredInteractionCount = 0;
|
|
410
|
+
for (const p of participants) {
|
|
411
|
+
unfilteredInteractionCount += asArray(p.interactions).length;
|
|
412
|
+
}
|
|
413
|
+
const totals_unfiltered = {
|
|
414
|
+
participant_count: participants.length,
|
|
415
|
+
interaction_count: unfilteredInteractionCount,
|
|
416
|
+
};
|
|
417
|
+
// --- Walk participants. ---
|
|
418
|
+
const out = [];
|
|
419
|
+
for (const participant of participants) {
|
|
420
|
+
// 1. Participant-level filters (drop the whole row up-front).
|
|
421
|
+
if (matchedParticipantId !== null &&
|
|
422
|
+
asString(participant.id) !== matchedParticipantId) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (matchedIterationId !== null &&
|
|
426
|
+
asString(participant.iteration_id) !== matchedIterationId) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
// 2. --step is per-participant: rewrites participant_assignments
|
|
430
|
+
// and surfaces evidence_interaction_ids if --include-evidence.
|
|
431
|
+
let evidenceInteractionIds = null;
|
|
432
|
+
let rewrittenAssignments = null;
|
|
433
|
+
if (filters.step !== undefined) {
|
|
434
|
+
const stepResult = filterStepsOnParticipant(participant, filters.step);
|
|
435
|
+
if (!stepResult) {
|
|
436
|
+
// This participant has no matching step verdicts — drop entirely
|
|
437
|
+
// (--step is participant-level "matched at least one verdict").
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
rewrittenAssignments = stepResult.filteredAssignments;
|
|
441
|
+
if (filters.includeEvidence) {
|
|
442
|
+
evidenceInteractionIds = stepResult.evidenceInteractionIds;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// 3. Walk interactions[] and drop those failing any predicate.
|
|
446
|
+
const survivingInteractions = [];
|
|
447
|
+
for (const raw of asArray(participant.interactions)) {
|
|
448
|
+
const interaction = asRecord(raw);
|
|
449
|
+
if (!interaction)
|
|
450
|
+
continue;
|
|
451
|
+
// --frame (interactive only)
|
|
452
|
+
if (frameActive) {
|
|
453
|
+
const fvId = asString(interaction.frame_version_id);
|
|
454
|
+
if (fvId === null) {
|
|
455
|
+
if (!filters.includeUnmatched)
|
|
456
|
+
continue;
|
|
457
|
+
// Keep this row; the projection layer surfaces it under the
|
|
458
|
+
// synthetic `_unmatched` bucket. We don't mutate the row here;
|
|
459
|
+
// the lookup map simply lacks an entry, which the projection
|
|
460
|
+
// layer interprets as "_unmatched".
|
|
461
|
+
}
|
|
462
|
+
else if (!matchedFrameVersionIds.has(fvId)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// --segment (video/audio/text/document)
|
|
467
|
+
if (segmentActive) {
|
|
468
|
+
if (!interactionMatchesSegment(interaction, filters.segment)) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// --turn (chat)
|
|
473
|
+
if (turnActive) {
|
|
474
|
+
if (!interactionMatchesTurn(interaction, filters.turn)) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// --side (participant_pair chat)
|
|
479
|
+
if (sideActive) {
|
|
480
|
+
if (!interactionMatchesSide(interaction, assignmentSideById, filters.side)) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// --assignment
|
|
485
|
+
if (matchedAssignmentIds !== null) {
|
|
486
|
+
const aid = asString(interaction.assignment_id);
|
|
487
|
+
if (!aid || !matchedAssignmentIds.has(aid))
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
// --actor
|
|
491
|
+
if (actorFilter !== null) {
|
|
492
|
+
const actor = asString(interaction.actor);
|
|
493
|
+
if (!actor || actor.toLowerCase() !== actorFilter)
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
// --sentiment (drops null-sentiment rows only when this filter set)
|
|
497
|
+
if (sentimentFilter !== null) {
|
|
498
|
+
const sentiment = asRecord(interaction.sentiment);
|
|
499
|
+
const label = sentiment ? asString(sentiment.label) : null;
|
|
500
|
+
if (!label || !sentimentFilter.has(label.toLowerCase()))
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// --include-evidence (paired with --step)
|
|
504
|
+
if (evidenceInteractionIds !== null) {
|
|
505
|
+
const iid = asString(interaction.id);
|
|
506
|
+
if (!iid || !evidenceInteractionIds.has(iid))
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
survivingInteractions.push(interaction);
|
|
510
|
+
}
|
|
511
|
+
// 4. Drop participants with zero surviving interactions only when
|
|
512
|
+
// an interaction-level filter is set (preserves the stable schema
|
|
513
|
+
// when the caller just asked "who ran?" without slicing).
|
|
514
|
+
if (interactionLevelFilterActive && survivingInteractions.length === 0) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const next = {
|
|
518
|
+
...participant,
|
|
519
|
+
interactions: survivingInteractions,
|
|
520
|
+
};
|
|
521
|
+
if (rewrittenAssignments !== null) {
|
|
522
|
+
next.participant_assignments = rewrittenAssignments;
|
|
523
|
+
}
|
|
524
|
+
out.push(next);
|
|
525
|
+
}
|
|
526
|
+
// The frames list returned to the caller is the raw payload (only the
|
|
527
|
+
// surface uses it for the `--get frames` shortcut). The projection
|
|
528
|
+
// layer relies on `frameVersionLookup` instead.
|
|
529
|
+
return {
|
|
530
|
+
study: { ...study },
|
|
531
|
+
participants: out,
|
|
532
|
+
frames: rawFrames,
|
|
533
|
+
totals_unfiltered,
|
|
534
|
+
warnings,
|
|
535
|
+
matchedFrameVersionIds,
|
|
536
|
+
frameVersionLookup,
|
|
537
|
+
};
|
|
538
|
+
}
|