@ishlabs/cli 0.15.0 → 0.17.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/README.md +10 -0
- package/dist/commands/ask.js +2 -0
- package/dist/commands/profile.js +10 -2
- package/dist/commands/study-run.js +114 -45
- package/dist/commands/workspace.js +3 -2
- package/dist/connect.js +8 -6
- package/dist/index.js +1 -1
- package/dist/lib/api-client.d.ts +7 -0
- package/dist/lib/api-client.js +9 -0
- package/dist/lib/command-helpers.d.ts +2 -0
- package/dist/lib/command-helpers.js +20 -4
- package/dist/lib/docs.js +31 -16
- package/dist/lib/skill-content.js +190 -484
- package/dist/lib/study-events.d.ts +46 -0
- package/dist/lib/study-events.js +126 -0
- package/dist/lib/types.d.ts +0 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -308,6 +308,16 @@ ish ask run --new --name "newsletter" \
|
|
|
308
308
|
|
|
309
309
|
**Audience flags** (`--profile`, `--sample`, `--all-simulatable`, `--country`, `--gender`, `--min-age`, `--max-age`, `--search`, `--visibility`, `--name`, `--description`, `--workspace`) only apply with `--new` — the audience is fixed at ask creation.
|
|
310
310
|
|
|
311
|
+
**`--visibility` values** (same set everywhere it's accepted):
|
|
312
|
+
|
|
313
|
+
| Value | Selects |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `workspace` | Profiles owned by your workspace (default scope). |
|
|
316
|
+
| `shared` | Community-published profiles visible across workspaces. |
|
|
317
|
+
| `platform` | Admin-curated profiles from the platform pool. |
|
|
318
|
+
|
|
319
|
+
Old values `private` (now `workspace`) and `public` (now `platform`) keep working until the next release; the server logs a deprecation warning and maps them to the new vocabulary.
|
|
320
|
+
|
|
311
321
|
**Other ask verbs:**
|
|
312
322
|
|
|
313
323
|
```bash
|
package/dist/commands/ask.js
CHANGED
package/dist/commands/profile.js
CHANGED
|
@@ -30,7 +30,9 @@ Concept pages: ish docs get-page concepts/profile
|
|
|
30
30
|
.command("list")
|
|
31
31
|
.description("List profiles (defaults to simulatable AI profiles)")
|
|
32
32
|
.option("--workspace <id>", "Filter by workspace ID")
|
|
33
|
-
.option("--search <query>", "
|
|
33
|
+
.option("--search <query>", "Substring match against profile name")
|
|
34
|
+
.option("--bio <text>", "Substring match against profile bio")
|
|
35
|
+
.option("--occupation <text>", "Substring match against profile occupation (repeatable)", collect, [])
|
|
34
36
|
.option("--type <type>", "Profile type: ai, human, all (default: ai)", "ai")
|
|
35
37
|
.option("--gender <gender>", "Filter by gender (repeatable)", collect, [])
|
|
36
38
|
.option("--country <country>", "Filter by country code, e.g. US (repeatable)", collect, [])
|
|
@@ -42,10 +44,12 @@ Concept pages: ish docs get-page concepts/profile
|
|
|
42
44
|
Examples:
|
|
43
45
|
$ ish profile list
|
|
44
46
|
$ ish profile list --search "engineer" --country US
|
|
47
|
+
$ ish profile list --bio "voice-first user"
|
|
48
|
+
$ ish profile list --occupation founder --occupation designer
|
|
45
49
|
$ ish profile list --gender female --gender male --country US --country GB
|
|
46
50
|
$ ish profile list --type all --json
|
|
47
51
|
|
|
48
|
-
# Pagination
|
|
52
|
+
# Pagination: default --limit is 50, iterate with --offset.
|
|
49
53
|
$ ish profile list --limit 100
|
|
50
54
|
$ ish profile list --limit 100 --offset 100 # next page
|
|
51
55
|
# When more results exist, a stderr hint surfaces the next --offset / --limit.`)
|
|
@@ -58,6 +62,10 @@ Examples:
|
|
|
58
62
|
};
|
|
59
63
|
if (opts.search)
|
|
60
64
|
params.search = opts.search;
|
|
65
|
+
if (opts.bio)
|
|
66
|
+
params.bio = opts.bio;
|
|
67
|
+
if (opts.occupation.length > 0)
|
|
68
|
+
params.occupation = opts.occupation;
|
|
61
69
|
if (opts.type !== "all")
|
|
62
70
|
params.type = opts.type;
|
|
63
71
|
if (opts.gender.length > 0)
|
|
@@ -11,6 +11,7 @@ import * as readline from "node:readline/promises";
|
|
|
11
11
|
import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, hasAudienceFlags, readFileOrStdin, } from "../lib/command-helpers.js";
|
|
12
12
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
13
13
|
import { output, formatSimulationPoll } from "../lib/output.js";
|
|
14
|
+
import { streamStudyEvents } from "../lib/study-events.js";
|
|
14
15
|
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readTesterPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
|
|
15
16
|
import { runLocalSimulations } from "../lib/local-sim/loop.js";
|
|
16
17
|
import { ensureBrowser } from "../lib/local-sim/install.js";
|
|
@@ -93,6 +94,11 @@ function dedupeSimulations(simResults) {
|
|
|
93
94
|
];
|
|
94
95
|
}
|
|
95
96
|
const POLL_INTERVAL_MS = 5_000;
|
|
97
|
+
// When the backend SSE stream is connected we drop to a slow backstop —
|
|
98
|
+
// events wake us up promptly, so re-fetching every 5s is wasteful. If the
|
|
99
|
+
// stream dies (older backend, broker offline, token mint failure) the loop
|
|
100
|
+
// transparently reverts to POLL_INTERVAL_MS.
|
|
101
|
+
const SSE_BACKSTOP_INTERVAL_MS = 30_000;
|
|
96
102
|
const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
|
|
97
103
|
function flattenTesterStatuses(iterations, only) {
|
|
98
104
|
const rows = [];
|
|
@@ -118,38 +124,90 @@ function flattenTesterStatuses(iterations, only) {
|
|
|
118
124
|
async function pollStudyUntilDone(client, opts) {
|
|
119
125
|
const start = Date.now();
|
|
120
126
|
let lastReported = "";
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
127
|
+
// Open the SSE event stream as a wake source. When the backend supports
|
|
128
|
+
// it (enable_realtime=true + broker live), tester status changes wake
|
|
129
|
+
// the loop the moment they happen — no need to wait for the next poll
|
|
130
|
+
// tick. When it doesn't, the iterator exits silently on the first read
|
|
131
|
+
// and we fall through to pure polling at POLL_INTERVAL_MS.
|
|
132
|
+
const ac = new AbortController();
|
|
133
|
+
const eventIter = streamStudyEvents(client, opts.studyId, ac.signal);
|
|
134
|
+
// Optimistic: we assume the stream will deliver until the first read
|
|
135
|
+
// confirms otherwise. The first `await pendingEvent` settles within
|
|
136
|
+
// milliseconds for an unavailable stream (token mint 503 / endpoint 503)
|
|
137
|
+
// and is essentially a no-op for the latency budget.
|
|
138
|
+
let sseAlive = true;
|
|
139
|
+
let pendingEvent = eventIter.next();
|
|
140
|
+
try {
|
|
141
|
+
while (true) {
|
|
142
|
+
const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
|
|
143
|
+
const isMedia = isMediaModality(study.modality);
|
|
144
|
+
let iterations = study.iterations;
|
|
145
|
+
if (opts.iterationId) {
|
|
146
|
+
iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
|
|
147
|
+
}
|
|
148
|
+
const rows = flattenTesterStatuses(iterations, opts.testerIds);
|
|
149
|
+
const total = rows.length;
|
|
150
|
+
const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
|
|
151
|
+
const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
|
|
152
|
+
const summary = `${done}/${total} done${errored > 0 ? `, ${errored} errored/failed` : ""}`;
|
|
153
|
+
if (!opts.quiet && summary !== lastReported) {
|
|
154
|
+
process.stderr.write(` ${summary}\n`);
|
|
155
|
+
lastReported = summary;
|
|
156
|
+
}
|
|
157
|
+
if (total > 0 && done === total) {
|
|
158
|
+
return { rows, isMedia };
|
|
159
|
+
}
|
|
160
|
+
if (Date.now() - start > opts.timeoutMs) {
|
|
161
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
|
|
162
|
+
`${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
|
|
163
|
+
study_id: opts.studyId,
|
|
164
|
+
...(opts.iterationId && { iteration_id: opts.iterationId }),
|
|
165
|
+
timeout_seconds: Math.round(opts.timeoutMs / 1000),
|
|
166
|
+
done,
|
|
167
|
+
total,
|
|
168
|
+
pending: total - done,
|
|
169
|
+
rows,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Wait for either the next backend event or the next tick. Use the
|
|
173
|
+
// backstop interval while the stream is delivering (events are the
|
|
174
|
+
// primary wake source); fall back to POLL_INTERVAL_MS once the
|
|
175
|
+
// stream is dead (older backend or broker dropped mid-run).
|
|
176
|
+
const interval = sseAlive ? SSE_BACKSTOP_INTERVAL_MS : POLL_INTERVAL_MS;
|
|
177
|
+
if (sseAlive && pendingEvent !== null) {
|
|
178
|
+
let timerHandle;
|
|
179
|
+
const timer = new Promise((resolve) => {
|
|
180
|
+
timerHandle = setTimeout(() => resolve({ kind: "timer" }), interval);
|
|
181
|
+
});
|
|
182
|
+
const eventOrEnd = pendingEvent.then((r) => ({ kind: "event", result: r }), () => ({ kind: "event", result: { value: undefined, done: true } }));
|
|
183
|
+
const winner = await Promise.race([eventOrEnd, timer]);
|
|
184
|
+
if (timerHandle !== undefined)
|
|
185
|
+
clearTimeout(timerHandle);
|
|
186
|
+
if (winner.kind === "event") {
|
|
187
|
+
if (winner.result.done) {
|
|
188
|
+
// Stream ended; revert to pure polling for the rest of the run.
|
|
189
|
+
sseAlive = false;
|
|
190
|
+
pendingEvent = null;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// Re-arm for the next event. We deliberately don't act on
|
|
194
|
+
// the event payload here — the next loop iteration re-fetches
|
|
195
|
+
// status and that's the truth source.
|
|
196
|
+
pendingEvent = eventIter.next();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Timer winning doesn't replace pendingEvent — it stays parked
|
|
200
|
+
// and resolves on whichever event arrives next.
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
204
|
+
}
|
|
151
205
|
}
|
|
152
|
-
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
// Stop the SSE fetch + reader so we don't leave a dangling HTTP
|
|
209
|
+
// connection to the backend after returning / throwing.
|
|
210
|
+
ac.abort();
|
|
153
211
|
}
|
|
154
212
|
}
|
|
155
213
|
function readIterationDetails(details) {
|
|
@@ -202,9 +260,10 @@ Note: --workspace and --study are optional if you have set active context
|
|
|
202
260
|
first with \`ish iteration create\`.
|
|
203
261
|
|
|
204
262
|
Audience: pass nothing to reuse the iteration's existing testers. Pass
|
|
205
|
-
--profile to use specific profiles, or filter flags (--
|
|
206
|
-
--min-age, --max-age, --search, --visibility)
|
|
207
|
-
to seed a fresh audience from the workspace
|
|
263
|
+
--profile to use specific profiles, or filter flags (--bio, --country,
|
|
264
|
+
--gender, --min-age, --max-age, --occupation, --search, --visibility)
|
|
265
|
+
with --sample <N> or --all to seed a fresh audience from the workspace
|
|
266
|
+
pool.
|
|
208
267
|
|
|
209
268
|
Examples:
|
|
210
269
|
# Run the latest iteration, reusing its testers:
|
|
@@ -395,15 +454,22 @@ Examples:
|
|
|
395
454
|
throw new Error(`Iteration "${iterationLabel}" has no testers and no audience flags were given. ` +
|
|
396
455
|
"Pass --profile <ids>, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility) with --sample <N> or --all.");
|
|
397
456
|
}
|
|
398
|
-
// Step 3: Resolve simulation config
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
457
|
+
// Step 3: Resolve simulation config. Always pre-flight every profile
|
|
458
|
+
// when no --config override is given: missing simulation_config_id
|
|
459
|
+
// is fatal across all modalities (media + chat batch dispatch use it
|
|
460
|
+
// per-item; interactive + pair dispatch fail server-side on the
|
|
461
|
+
// first sim start) and creating tester rows before discovering it
|
|
462
|
+
// leaves phantom DRAFT rows in the iteration. Pair mode reads
|
|
463
|
+
// pairConfig.audience_a; non-pair uses profileIds. profileConfigMap
|
|
464
|
+
// is consumed by the media branch; other branches just need the
|
|
465
|
+
// validation side effect.
|
|
403
466
|
const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
|
|
404
467
|
const profileConfigMap = new Map();
|
|
405
|
-
if (
|
|
406
|
-
|
|
468
|
+
if (!resolvedConfigOverride) {
|
|
469
|
+
const idsToCheck = isPair && pairConfig
|
|
470
|
+
? [...new Set([...pairConfig.audience_a, ...pairConfig.audience_b])]
|
|
471
|
+
: profileIds;
|
|
472
|
+
for (const pid of idsToCheck) {
|
|
407
473
|
const profile = await client.get(`/tester-profiles/${pid}`);
|
|
408
474
|
if (profile.simulation_config_id) {
|
|
409
475
|
profileConfigMap.set(pid, profile.simulation_config_id);
|
|
@@ -732,18 +798,21 @@ Examples:
|
|
|
732
798
|
// Fall back to the first audience_a profile's
|
|
733
799
|
// simulation_config_id. Pair dispatch takes a single config
|
|
734
800
|
// for the whole batch, so we don't need the per-profile map
|
|
735
|
-
// the external_chatbot path builds.
|
|
801
|
+
// the external_chatbot path builds. Step 3 already populated
|
|
802
|
+
// profileConfigMap with every audience profile's config when
|
|
803
|
+
// --config was not passed, so reuse that.
|
|
736
804
|
const fallbackProfileId = pairConfig.audience_a[0];
|
|
737
805
|
if (!fallbackProfileId) {
|
|
738
806
|
throw new Error("Pair-mode dispatch requires --config <id>: the iteration has no audience profile to draw a default config_id from.");
|
|
739
807
|
}
|
|
740
|
-
|
|
741
|
-
if (!
|
|
808
|
+
pairConfigId = profileConfigMap.get(fallbackProfileId);
|
|
809
|
+
if (!pairConfigId) {
|
|
810
|
+
// Defensive: Step 3 should have either populated the map or
|
|
811
|
+
// thrown. If we land here something upstream changed.
|
|
742
812
|
throw new Error(`Pair-mode dispatch requires a config_id. Profile ${fallbackProfileId} has no simulation config assigned and --config was not passed.\n` +
|
|
743
813
|
"Use --config <id> to specify one, or assign a config to the profile.\n" +
|
|
744
814
|
"List configs with: ish config list");
|
|
745
815
|
}
|
|
746
|
-
pairConfigId = fallbackProfile.simulation_config_id;
|
|
747
816
|
}
|
|
748
817
|
const simResult = await dispatchAttempt(() => client.post("/simulation/chat/pair/start/batch", {
|
|
749
818
|
product_id: resolvedWorkspace,
|
|
@@ -214,11 +214,12 @@ async function collectWorkspaceUsage(client, workspaceId) {
|
|
|
214
214
|
.get(`/products/${workspaceId}/studies`)
|
|
215
215
|
.catch(() => []);
|
|
216
216
|
// testers_used: paginated list returns { total, items, ... }. Backend
|
|
217
|
-
// gates `maxCustomTesterProfiles` on visibility=
|
|
217
|
+
// gates `maxCustomTesterProfiles` on visibility=workspace (rows owned by
|
|
218
|
+
// this workspace; was `private` before the visibility rename).
|
|
218
219
|
const testersPromise = client
|
|
219
220
|
.get("/tester-profiles", {
|
|
220
221
|
product_id: workspaceId,
|
|
221
|
-
visibility: "
|
|
222
|
+
visibility: "workspace",
|
|
222
223
|
type: "ai",
|
|
223
224
|
limit: "1",
|
|
224
225
|
offset: "0",
|
package/dist/connect.js
CHANGED
|
@@ -284,12 +284,14 @@ async function resolveToken(tokenArg, apiUrl, tokenFileArg) {
|
|
|
284
284
|
// --- Branding ---
|
|
285
285
|
function printBanner() {
|
|
286
286
|
console.log(`
|
|
287
|
-
${c.orange}${c.bold}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
287
|
+
${c.orange}${c.bold} /██ /██
|
|
288
|
+
|__/ | ██
|
|
289
|
+
/██ /███████| ███████
|
|
290
|
+
| ██ /██_____/| ██__ ██
|
|
291
|
+
| ██| ██████ | ██ \\ ██
|
|
292
|
+
| ██ \\____ ██| ██ | ██
|
|
293
|
+
| ██ /███████/| ██ | ██
|
|
294
|
+
|__/|_______/ |__/ |__/${c.reset}
|
|
293
295
|
|
|
294
296
|
Connected
|
|
295
297
|
`);
|
package/dist/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import pkg from "../package.json" with { type: "json" };
|
|
|
30
30
|
const { version } = pkg;
|
|
31
31
|
program
|
|
32
32
|
.name("ish")
|
|
33
|
-
.description("
|
|
33
|
+
.description("ish CLI — run studies and asks against AI tester audiences")
|
|
34
34
|
.version(version)
|
|
35
35
|
.addHelpText("after", AGENT_HELP_FOOTER);
|
|
36
36
|
// Unified error envelope for Commander-level failures (unknown command,
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export declare class ApiClient {
|
|
|
20
20
|
token: string;
|
|
21
21
|
});
|
|
22
22
|
get accessToken(): string;
|
|
23
|
+
/** Resolved base URL including the ``/api/v1`` prefix.
|
|
24
|
+
*
|
|
25
|
+
* Exposed so helpers that bypass the JSON-decoding ``fetch`` wrappers
|
|
26
|
+
* (e.g. the SSE stream in ``lib/study-events.ts``) can build their own
|
|
27
|
+
* requests against the same backend.
|
|
28
|
+
*/
|
|
29
|
+
get apiBase(): string;
|
|
23
30
|
private headers;
|
|
24
31
|
get<T = unknown>(path: string, params?: Record<string, string | string[]>, opts?: RequestOpts): Promise<T>;
|
|
25
32
|
post<T = unknown>(path: string, body?: unknown, opts?: RequestOpts): Promise<T>;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -83,6 +83,15 @@ export class ApiClient {
|
|
|
83
83
|
get accessToken() {
|
|
84
84
|
return this.token;
|
|
85
85
|
}
|
|
86
|
+
/** Resolved base URL including the ``/api/v1`` prefix.
|
|
87
|
+
*
|
|
88
|
+
* Exposed so helpers that bypass the JSON-decoding ``fetch`` wrappers
|
|
89
|
+
* (e.g. the SSE stream in ``lib/study-events.ts``) can build their own
|
|
90
|
+
* requests against the same backend.
|
|
91
|
+
*/
|
|
92
|
+
get apiBase() {
|
|
93
|
+
return this.baseUrl;
|
|
94
|
+
}
|
|
86
95
|
headers() {
|
|
87
96
|
return {
|
|
88
97
|
Authorization: `Bearer ${this.token}`,
|
|
@@ -33,6 +33,10 @@ function describeFilters(flags) {
|
|
|
33
33
|
const parts = [];
|
|
34
34
|
if (flags.search)
|
|
35
35
|
parts.push(`--search "${flags.search}"`);
|
|
36
|
+
if (flags.bio)
|
|
37
|
+
parts.push(`--bio "${flags.bio}"`);
|
|
38
|
+
if (flags.occupation?.length)
|
|
39
|
+
parts.push(...flags.occupation.map((o) => `--occupation ${o}`));
|
|
36
40
|
if (flags.gender?.length)
|
|
37
41
|
parts.push(...flags.gender.map((g) => `--gender ${g}`));
|
|
38
42
|
if (flags.country?.length)
|
|
@@ -75,6 +79,10 @@ async function suggestCountries(client, workspace, flags, opts) {
|
|
|
75
79
|
if (keepOtherFilters) {
|
|
76
80
|
if (flags.search)
|
|
77
81
|
broader.search = flags.search;
|
|
82
|
+
if (flags.bio)
|
|
83
|
+
broader.bio = flags.bio;
|
|
84
|
+
if (flags.occupation && flags.occupation.length > 0)
|
|
85
|
+
broader.occupation = flags.occupation;
|
|
78
86
|
if (flags.gender && flags.gender.length > 0)
|
|
79
87
|
broader.gender = flags.gender;
|
|
80
88
|
if (flags.minAge)
|
|
@@ -118,6 +126,8 @@ async function suggestCountries(client, workspace, flags, opts) {
|
|
|
118
126
|
}
|
|
119
127
|
function hasFilterFlag(flags) {
|
|
120
128
|
return Boolean(flags.search
|
|
129
|
+
|| flags.bio
|
|
130
|
+
|| (flags.occupation && flags.occupation.length > 0)
|
|
121
131
|
|| (flags.gender && flags.gender.length > 0)
|
|
122
132
|
|| (flags.country && flags.country.length > 0)
|
|
123
133
|
|| flags.minAge
|
|
@@ -153,7 +163,7 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
|
|
|
153
163
|
const filtersUsed = hasFilterFlag(flags);
|
|
154
164
|
if (explicit.length > 0) {
|
|
155
165
|
if (sampleN !== undefined || flags.all || filtersUsed) {
|
|
156
|
-
throw new Error(`Use either explicit --profile flags or --sample/${allFlagName}/filter flags (--country, --gender, --min-age, --max-age, --search, --visibility), not both.`);
|
|
166
|
+
throw new Error(`Use either explicit --profile flags or --sample/${allFlagName}/filter flags (--bio, --country, --gender, --min-age, --max-age, --occupation, --search, --visibility), not both.`);
|
|
157
167
|
}
|
|
158
168
|
return explicit;
|
|
159
169
|
}
|
|
@@ -161,7 +171,7 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
|
|
|
161
171
|
throw new Error(`Use either --sample <N> or ${allFlagName}, not both. --sample picks a random subset; ${allFlagName} returns every match.`);
|
|
162
172
|
}
|
|
163
173
|
if (sampleN === undefined && !flags.all && !filtersUsed) {
|
|
164
|
-
throw new Error(`Pick an audience: pass --profile <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility).`);
|
|
174
|
+
throw new Error(`Pick an audience: pass --profile <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--bio, --country, --gender, --min-age, --max-age, --occupation, --search, --visibility).`);
|
|
165
175
|
}
|
|
166
176
|
const params = {
|
|
167
177
|
product_id: workspace,
|
|
@@ -171,6 +181,10 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
|
|
|
171
181
|
};
|
|
172
182
|
if (flags.search)
|
|
173
183
|
params.search = flags.search;
|
|
184
|
+
if (flags.bio)
|
|
185
|
+
params.bio = flags.bio;
|
|
186
|
+
if (flags.occupation && flags.occupation.length > 0)
|
|
187
|
+
params.occupation = flags.occupation;
|
|
174
188
|
if (flags.gender && flags.gender.length > 0)
|
|
175
189
|
params.gender = flags.gender;
|
|
176
190
|
if (flags.country && flags.country.length > 0)
|
|
@@ -240,12 +254,14 @@ export function addAudienceFilterFlags(cmd, opts = {}) {
|
|
|
240
254
|
.option("--profile <ids>", "Tester profile IDs/aliases (comma-separated or repeatable)", collectIds, [])
|
|
241
255
|
.option("--sample <N>", "Randomly sample N profiles from the matching pool")
|
|
242
256
|
.option(allFlag, allDesc)
|
|
243
|
-
.option("--search <text>", "
|
|
257
|
+
.option("--search <text>", "Substring match against profile name")
|
|
258
|
+
.option("--bio <text>", "Substring match against profile bio")
|
|
259
|
+
.option("--occupation <text>", "Substring match against profile occupation (repeatable)", collectRepeatable, [])
|
|
244
260
|
.option("--gender <gender>", "Filter by gender (repeatable)", collectRepeatable, [])
|
|
245
261
|
.option("--country <code>", "Filter by 2-letter country code (repeatable)", collectRepeatable, [])
|
|
246
262
|
.option("--min-age <n>", "Minimum age (inclusive)")
|
|
247
263
|
.option("--max-age <n>", "Maximum age (inclusive)")
|
|
248
|
-
.option("--visibility <v>", "Filter by visibility (private
|
|
264
|
+
.option("--visibility <v>", "Filter by visibility: workspace (your workspace), shared (community-published), platform (admin-curated). Old values `private` / `public` still accepted as aliases for `workspace` / `platform` until the next release; server logs a deprecation warning.");
|
|
249
265
|
}
|
|
250
266
|
export function getGlobals(cmd) {
|
|
251
267
|
let globals;
|
package/dist/lib/docs.js
CHANGED
|
@@ -1267,8 +1267,15 @@ flags. Two ways to select:
|
|
|
1267
1267
|
- \`--gender female\` (repeatable)
|
|
1268
1268
|
- \`--min-age 25\`
|
|
1269
1269
|
- \`--max-age 50\`
|
|
1270
|
-
- \`--search "
|
|
1271
|
-
- \`--
|
|
1270
|
+
- \`--search "Anna"\` (substring match against profile name)
|
|
1271
|
+
- \`--bio "voice-first user"\` (substring match against profile bio)
|
|
1272
|
+
- \`--occupation founder\` (substring match against profile occupation; repeatable, OR semantics)
|
|
1273
|
+
- \`--visibility workspace|shared|platform\` (filter by where the
|
|
1274
|
+
profile lives: your workspace, the community-published pool, or
|
|
1275
|
+
the admin-curated platform pool; old values \`private\` /
|
|
1276
|
+
\`public\` still accepted as aliases for \`workspace\` /
|
|
1277
|
+
\`platform\` until the next release with a server-side
|
|
1278
|
+
deprecation warning)
|
|
1272
1279
|
|
|
1273
1280
|
The two modes are **mutually exclusive** — pass either \`--profile\` or
|
|
1274
1281
|
the filter set, not both.
|
|
@@ -1297,20 +1304,22 @@ Two adjacent footguns surface most often on first-time audience
|
|
|
1297
1304
|
construction. Both are documented here because they cost a round-trip
|
|
1298
1305
|
to discover by experiment.
|
|
1299
1306
|
|
|
1300
|
-
###
|
|
1301
|
-
|
|
1302
|
-
\`audience_build\`
|
|
1303
|
-
|
|
1304
|
-
taxonomy match.
|
|
1305
|
-
retail store managers, bank branch managers
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
- **Whole-token alternation**:
|
|
1310
|
-
"software engineering manager"
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1307
|
+
### \`--occupation\` is a loose substring match
|
|
1308
|
+
|
|
1309
|
+
\`audience_build\` and the \`--occupation\` flag treat the value as a
|
|
1310
|
+
**loose, case-insensitive substring filter**, not a whole-token or
|
|
1311
|
+
taxonomy match. \`--occupation manager\` will match hotel managers,
|
|
1312
|
+
retail store managers, bank branch managers: anything containing the
|
|
1313
|
+
literal string "manager". Three patterns that recover the specificity
|
|
1314
|
+
you usually want:
|
|
1315
|
+
|
|
1316
|
+
- **Whole-token alternation**: \`--occupation "engineering manager"
|
|
1317
|
+
--occupation "software engineering manager" --occupation "vp
|
|
1318
|
+
engineering" --occupation "tech lead"\`: exhaustive enumeration of
|
|
1319
|
+
the role surface beats one short token. Multiple \`--occupation\`
|
|
1320
|
+
flags OR together server-side.
|
|
1321
|
+
- **Pair with other filters**: \`--occupation manager --min-age 28
|
|
1322
|
+
--country US --country SE\` narrows even a loose substring
|
|
1314
1323
|
meaningfully.
|
|
1315
1324
|
- **Preview before dispatch**: \`audience_build\` returns a
|
|
1316
1325
|
\`match_preview\` summary on the response — a 1-line histogram of
|
|
@@ -1353,6 +1362,12 @@ ish study run --profile tp-795,tp-af2
|
|
|
1353
1362
|
# Sample 3 Swedish profiles aged 35-50:
|
|
1354
1363
|
ish study run --country SE --min-age 35 --max-age 50 --sample 3
|
|
1355
1364
|
|
|
1365
|
+
# Every female founder or designer:
|
|
1366
|
+
ish study run --gender female --occupation founder --occupation designer --all
|
|
1367
|
+
|
|
1368
|
+
# Bio substring (e.g. accessibility cohort):
|
|
1369
|
+
ish study run --bio "screen reader" --all
|
|
1370
|
+
|
|
1356
1371
|
# Every female profile in the workspace:
|
|
1357
1372
|
ish study run --gender female --all
|
|
1358
1373
|
|