@ishlabs/cli 0.18.0 → 0.19.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-run.js +57 -30
- package/dist/commands/study.js +17 -6
- package/dist/lib/docs.js +10 -1
- package/dist/lib/output.d.ts +3 -3
- package/dist/lib/output.js +64 -59
- package/dist/lib/skill-content.js +1 -0
- package/dist/lib/study-participants.d.ts +32 -0
- package/dist/lib/study-participants.js +12 -0
- package/dist/lib/types.d.ts +0 -1
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import * as readline from "node:readline/promises";
|
|
|
12
12
|
import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolvePersonIds, addPersonFilterFlags, hasPersonFlags, readFileOrStdin, } from "../lib/command-helpers.js";
|
|
13
13
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
14
14
|
import { output, formatSimulationPoll } from "../lib/output.js";
|
|
15
|
+
import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
15
16
|
import { streamStudyEvents } from "../lib/study-events.js";
|
|
16
17
|
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
|
|
17
18
|
// NOTE: local-sim modules are loaded via dynamic import at the `--local`
|
|
@@ -107,24 +108,27 @@ const POLL_INTERVAL_MS = 5_000;
|
|
|
107
108
|
// transparently reverts to POLL_INTERVAL_MS.
|
|
108
109
|
const SSE_BACKSTOP_INTERVAL_MS = 30_000;
|
|
109
110
|
const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
|
|
110
|
-
function flattenParticipantStatuses(
|
|
111
|
+
function flattenParticipantStatuses(participants, opts = {}) {
|
|
111
112
|
const rows = [];
|
|
112
|
-
for (const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
113
|
+
for (const t of participants ?? []) {
|
|
114
|
+
if (opts.iterationId && t.iteration_id !== opts.iterationId)
|
|
115
|
+
continue;
|
|
116
|
+
if (opts.only && !opts.only.has(t.id))
|
|
117
|
+
continue;
|
|
118
|
+
// Pattern A (cli half): backend now reports per-participant crash detail at
|
|
119
|
+
// `error_message`. Keep `error` / `failure_reason` as legacy fallbacks
|
|
120
|
+
// until every backend deploy is on the new contract.
|
|
121
|
+
const errorMessage = t.error_message ||
|
|
122
|
+
t.error ||
|
|
123
|
+
t.failure_reason ||
|
|
124
|
+
null;
|
|
125
|
+
rows.push({
|
|
126
|
+
id: t.id,
|
|
127
|
+
status: t.status,
|
|
128
|
+
participant_name: t.person?.name || "Unknown",
|
|
129
|
+
interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
|
|
130
|
+
...(errorMessage && { error_message: String(errorMessage) }),
|
|
131
|
+
});
|
|
128
132
|
}
|
|
129
133
|
return rows;
|
|
130
134
|
}
|
|
@@ -146,13 +150,15 @@ async function pollStudyUntilDone(client, opts) {
|
|
|
146
150
|
let pendingEvent = eventIter.next();
|
|
147
151
|
try {
|
|
148
152
|
while (true) {
|
|
149
|
-
const study = await
|
|
153
|
+
const [study, participants] = await Promise.all([
|
|
154
|
+
client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 }),
|
|
155
|
+
fetchStudyParticipants(client, opts.studyId, { timeout: 60_000 }),
|
|
156
|
+
]);
|
|
150
157
|
const isMedia = isMediaModality(study.modality);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
const rows = flattenParticipantStatuses(iterations, opts.participantIds);
|
|
158
|
+
const rows = flattenParticipantStatuses(participants, {
|
|
159
|
+
iterationId: opts.iterationId,
|
|
160
|
+
only: opts.participantIds,
|
|
161
|
+
});
|
|
156
162
|
const total = rows.length;
|
|
157
163
|
const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
|
|
158
164
|
const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
|
|
@@ -366,7 +372,8 @@ Examples:
|
|
|
366
372
|
|| parseInt(opts.dispatchTimeout, 10) < 1)) {
|
|
367
373
|
throw new Error(`--dispatch-timeout must be a positive integer (seconds), got "${opts.dispatchTimeout}".`);
|
|
368
374
|
}
|
|
369
|
-
// Step 0: Fetch study (
|
|
375
|
+
// Step 0: Fetch study metadata (lite — participants live on a
|
|
376
|
+
// separate endpoint; we fetch them below only when reuse is possible).
|
|
370
377
|
const study = await client.get(`/studies/${resolvedStudy}`);
|
|
371
378
|
const modality = study.modality || "interactive";
|
|
372
379
|
const isMedia = isMediaModality(modality);
|
|
@@ -455,8 +462,15 @@ Examples:
|
|
|
455
462
|
const resolved = await resolvePersonIds(client, resolvedWorkspace, opts, { requireSimulatable: false, allFlagName: "--all" });
|
|
456
463
|
personIds.push(...resolved);
|
|
457
464
|
}
|
|
458
|
-
else if (
|
|
459
|
-
|
|
465
|
+
else if (!isPair) {
|
|
466
|
+
// Reuse-existing path: no person flags, non-pair iteration. Fetch
|
|
467
|
+
// participants from the dedicated endpoint and filter to the
|
|
468
|
+
// current iteration. (Pair iterations don't reuse participant rows
|
|
469
|
+
// — they reuse Conversation refs above.)
|
|
470
|
+
const studyParticipants = await fetchStudyParticipants(client, resolvedStudy);
|
|
471
|
+
for (const t of studyParticipants) {
|
|
472
|
+
if (t.iteration_id !== iteration.id)
|
|
473
|
+
continue;
|
|
460
474
|
const pid = t.person_id || t.person?.id;
|
|
461
475
|
const name = t.person?.name;
|
|
462
476
|
if (pid && !personNames.has(pid)) {
|
|
@@ -667,12 +681,22 @@ Examples:
|
|
|
667
681
|
// language?: str }
|
|
668
682
|
// reply : { conversations: [{ conversation_id, pair_index,
|
|
669
683
|
// participant_a_id, participant_b_id }] }
|
|
684
|
+
//
|
|
685
|
+
// On the LITE study response, the iteration's conversation refs
|
|
686
|
+
// use the storage-shape field names group_a_participant_id /
|
|
687
|
+
// group_b_participant_id. Map them back to the pair-batch reply
|
|
688
|
+
// shape (participant_a_id / participant_b_id) the rest of this
|
|
689
|
+
// function expects.
|
|
670
690
|
const existingConvs = iteration.conversations ?? [];
|
|
671
691
|
const reusable = [];
|
|
672
692
|
for (const c of existingConvs) {
|
|
673
693
|
const cid = c.conversation_id || c.id;
|
|
674
|
-
if (cid && c.
|
|
675
|
-
reusable.push({
|
|
694
|
+
if (cid && c.group_a_participant_id && c.group_b_participant_id) {
|
|
695
|
+
reusable.push({
|
|
696
|
+
conversation_id: cid,
|
|
697
|
+
participant_a_id: c.group_a_participant_id,
|
|
698
|
+
participant_b_id: c.group_b_participant_id,
|
|
699
|
+
});
|
|
676
700
|
}
|
|
677
701
|
}
|
|
678
702
|
let pairRows;
|
|
@@ -1059,9 +1083,12 @@ Examples:
|
|
|
1059
1083
|
// an agent that ran `study use s-...` then `study poll` would get a
|
|
1060
1084
|
// confusing "Provide a participant_id argument or --study flag" error.
|
|
1061
1085
|
const rid = resolveStudy(opts.study);
|
|
1062
|
-
const study = await
|
|
1086
|
+
const [study, participants] = await Promise.all([
|
|
1087
|
+
client.get(`/studies/${rid}`),
|
|
1088
|
+
fetchStudyParticipants(client, rid),
|
|
1089
|
+
]);
|
|
1063
1090
|
const isMedia = isMediaModality(study.modality);
|
|
1064
|
-
const allParticipants = flattenParticipantStatuses(
|
|
1091
|
+
const allParticipants = flattenParticipantStatuses(participants);
|
|
1065
1092
|
formatSimulationPoll(allParticipants, globals.json, isMedia);
|
|
1066
1093
|
if (!globals.json && study.product_id) {
|
|
1067
1094
|
const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
|
package/dist/commands/study.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
|
8
8
|
import { loadConfig, saveConfig } from "../config.js";
|
|
9
9
|
import { formatStudyList, formatStudyDetail, formatStudyResults, buildStudyResultsSummary, buildChatTranscript, output, ValidationError, } from "../lib/output.js";
|
|
10
10
|
import { VALID_CONTENT_TYPES } from "../lib/types.js";
|
|
11
|
+
import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
11
12
|
import { parseAssignment, loadAssignmentsFile, validateAssignmentsArray, parseQuestion } from "../lib/study-inputs.js";
|
|
12
13
|
import { loadQuestionsManifest } from "../lib/ask-questions.js";
|
|
13
14
|
import { isLocalPath } from "../lib/upload.js";
|
|
@@ -616,14 +617,17 @@ list table layout in human mode.`)
|
|
|
616
617
|
throw new Error("Provide at least one study id.");
|
|
617
618
|
if (flat.length === 1) {
|
|
618
619
|
const rid = resolveId(flat[0]);
|
|
619
|
-
const data = await
|
|
620
|
+
const [data, participants] = await Promise.all([
|
|
621
|
+
client.get(`/studies/${rid}`),
|
|
622
|
+
fetchStudyParticipants(client, rid),
|
|
623
|
+
]);
|
|
620
624
|
const result = data;
|
|
621
625
|
if (result.id)
|
|
622
626
|
result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
|
|
623
627
|
if (data.product_id) {
|
|
624
628
|
result.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
625
629
|
}
|
|
626
|
-
formatStudyDetail(result, globals.json);
|
|
630
|
+
formatStudyDetail(result, globals.json, {}, participants);
|
|
627
631
|
if (!globals.json && data.product_id) {
|
|
628
632
|
const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
629
633
|
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
@@ -632,13 +636,17 @@ list table layout in human mode.`)
|
|
|
632
636
|
}
|
|
633
637
|
const results = await Promise.all(flat.map(async (raw) => {
|
|
634
638
|
const rid = resolveId(raw);
|
|
635
|
-
const data = await
|
|
639
|
+
const [data, participants] = await Promise.all([
|
|
640
|
+
client.get(`/studies/${rid}`),
|
|
641
|
+
fetchStudyParticipants(client, rid),
|
|
642
|
+
]);
|
|
636
643
|
const r = data;
|
|
637
644
|
if (r.id)
|
|
638
645
|
r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
|
|
639
646
|
if (data.product_id) {
|
|
640
647
|
r.url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
|
641
648
|
}
|
|
649
|
+
r.participants = participants;
|
|
642
650
|
return r;
|
|
643
651
|
}));
|
|
644
652
|
if (globals.json) {
|
|
@@ -751,12 +759,15 @@ When no runs have completed, the default envelope is returned with zero counts a
|
|
|
751
759
|
output(buildChatTranscript(participant), globals.json, { preProjected: true });
|
|
752
760
|
return;
|
|
753
761
|
}
|
|
754
|
-
const data = await
|
|
762
|
+
const [data, participants] = await Promise.all([
|
|
763
|
+
client.get(`/studies/${rid}`),
|
|
764
|
+
fetchStudyParticipants(client, rid),
|
|
765
|
+
]);
|
|
755
766
|
if (wantsSummary) {
|
|
756
|
-
output(buildStudyResultsSummary(data), globals.json, { preProjected: true });
|
|
767
|
+
output(buildStudyResultsSummary(data, participants), globals.json, { preProjected: true });
|
|
757
768
|
}
|
|
758
769
|
else {
|
|
759
|
-
formatStudyResults(data, globals.json);
|
|
770
|
+
formatStudyResults(data, participants, globals.json);
|
|
760
771
|
}
|
|
761
772
|
if (!globals.json && data.product_id) {
|
|
762
773
|
const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
|
package/dist/lib/docs.js
CHANGED
|
@@ -2059,6 +2059,15 @@ The CLI guarantees these contracts so agents can chain safely:
|
|
|
2059
2059
|
sentiment histogram + per-participant {alias, status, sentiment, comment,
|
|
2060
2060
|
error_message}. Drops \`interview_answers\` and per-interaction
|
|
2061
2061
|
breakdowns. Cheapest "did this run land?" shape.
|
|
2062
|
+
- **\`study get --json\` carries a flat top-level \`participants[]\`**
|
|
2063
|
+
(post backend-split). Each row carries \`iteration_id\` as a
|
|
2064
|
+
discriminator and the per-participant graph
|
|
2065
|
+
(\`person\`, \`interactions[]\`, \`participant_summary\`,
|
|
2066
|
+
\`interview_answers\`, \`conversation_id\`, …). The previous nesting
|
|
2067
|
+
under \`iterations[*].participants[*]\` is gone — read participants from
|
|
2068
|
+
the top level. The lite iteration list under \`iterations[]\` still
|
|
2069
|
+
carries each iteration's \`details\` and (for pair-mode chat) the
|
|
2070
|
+
conversation refs at \`iterations[*].conversations[]\`.
|
|
2062
2071
|
- **\`study get --json\` carries assignment step completion** when an
|
|
2063
2072
|
assignment has a checklist (see \`concepts/assignment\`). Each
|
|
2064
2073
|
\`assignments[].step_completion[]\` row is
|
|
@@ -3079,7 +3088,7 @@ ish study run stu-xyz --sample 5 --wait
|
|
|
3079
3088
|
|
|
3080
3089
|
Pull raw interactions:
|
|
3081
3090
|
\`\`\`
|
|
3082
|
-
ish study results stu-xyz --json | jq '.interactions'
|
|
3091
|
+
ish study results stu-xyz --json | jq '.participants[].interactions'
|
|
3083
3092
|
\`\`\`
|
|
3084
3093
|
|
|
3085
3094
|
Note: chat is currently excluded from the LLM-analysis route; the
|
package/dist/lib/output.d.ts
CHANGED
|
@@ -47,15 +47,15 @@ export declare function formatWorkspaceList(workspaces: Record<string, unknown>[
|
|
|
47
47
|
export declare function formatWorkspaceDetail(workspace: Record<string, unknown>, json: boolean, options?: OutputOptions): void;
|
|
48
48
|
export declare function formatSiteAccessStatus(summary: import("./site-access.js").SiteAccessSummary, json: boolean): void;
|
|
49
49
|
export declare function formatStudyList(studies: Record<string, unknown>[], json: boolean): void;
|
|
50
|
-
export declare function formatStudyDetail(study: Record<string, unknown>, json: boolean, options?: OutputOptions): void;
|
|
51
|
-
export declare function formatStudyResults(study: Record<string, unknown>, json: boolean): void;
|
|
50
|
+
export declare function formatStudyDetail(study: Record<string, unknown>, json: boolean, options?: OutputOptions, participants?: ReadonlyArray<Record<string, unknown>>): void;
|
|
51
|
+
export declare function formatStudyResults(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>, json: boolean): void;
|
|
52
52
|
/**
|
|
53
53
|
* `study results --summary` projection. Drops interview_answers + per-participant
|
|
54
54
|
* interaction breakdowns; keeps headline counters, sentiment histogram, and a
|
|
55
55
|
* per-participant {alias, status, sentiment, comment} row. Useful for agents that
|
|
56
56
|
* need to branch on outcome without paying for the full envelope.
|
|
57
57
|
*/
|
|
58
|
-
export declare function buildStudyResultsSummary(study: Record<string, unknown
|
|
58
|
+
export declare function buildStudyResultsSummary(study: Record<string, unknown>, participants: ReadonlyArray<Record<string, unknown>>): Record<string, unknown>;
|
|
59
59
|
/**
|
|
60
60
|
* `study results --transcript <participant_id>` projection. Mirrors the schema
|
|
61
61
|
* MCP's `get_chat_transcript` returns (`src/ish_mcp/projections.py:
|
package/dist/lib/output.js
CHANGED
|
@@ -807,10 +807,10 @@ export function formatStudyList(studies, json) {
|
|
|
807
807
|
*
|
|
808
808
|
* Returns null when status is consistent; no warning emitted.
|
|
809
809
|
*/
|
|
810
|
-
function detectStudyStatusInconsistency(study) {
|
|
810
|
+
function detectStudyStatusInconsistency(study, participants) {
|
|
811
811
|
if (study.status !== "failed")
|
|
812
812
|
return null;
|
|
813
|
-
const allParticipants = collectParticipants(study);
|
|
813
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
814
814
|
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
815
815
|
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
816
816
|
if (completedCount === 0 && totalInteractions === 0)
|
|
@@ -879,14 +879,16 @@ function renderAssignmentSteps(a) {
|
|
|
879
879
|
}
|
|
880
880
|
}
|
|
881
881
|
}
|
|
882
|
-
export function formatStudyDetail(study, json, options = {}) {
|
|
883
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
882
|
+
export function formatStudyDetail(study, json, options = {}, participants) {
|
|
883
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
884
884
|
if (inconsistency)
|
|
885
885
|
emitStatusInconsistencyWarning(inconsistency);
|
|
886
886
|
if (json) {
|
|
887
|
-
const payload =
|
|
888
|
-
|
|
889
|
-
|
|
887
|
+
const payload = { ...study };
|
|
888
|
+
if (participants !== undefined)
|
|
889
|
+
payload.participants = participants;
|
|
890
|
+
if (inconsistency)
|
|
891
|
+
payload.status_inferred = inconsistency.inferred;
|
|
890
892
|
console.log(jsonOutput(payload, options));
|
|
891
893
|
return;
|
|
892
894
|
}
|
|
@@ -929,7 +931,7 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
929
931
|
}
|
|
930
932
|
}
|
|
931
933
|
// Participants summary
|
|
932
|
-
const allParticipants = collectParticipants(study);
|
|
934
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
933
935
|
if (allParticipants.length > 0) {
|
|
934
936
|
console.log(`\nParticipants (${allParticipants.length}):`);
|
|
935
937
|
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
|
|
@@ -946,8 +948,8 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
946
948
|
* study state — fields default to `null`, `0`, or `[]` when nothing has run.
|
|
947
949
|
* Agents can rely on the keys always being present (M4).
|
|
948
950
|
*/
|
|
949
|
-
function buildStudyResultsEnvelope(study) {
|
|
950
|
-
const allParticipants = collectParticipants(study);
|
|
951
|
+
function buildStudyResultsEnvelope(study, participants) {
|
|
952
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
951
953
|
const studyAlias = study.id
|
|
952
954
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
953
955
|
: null;
|
|
@@ -995,7 +997,7 @@ function buildStudyResultsEnvelope(study) {
|
|
|
995
997
|
// CLI-side sanity check (Pattern E / Issue #2). Surface a status_inferred
|
|
996
998
|
// field when the backend reports failed-with-data; agents can branch on
|
|
997
999
|
// either the original status or status_inferred.
|
|
998
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
1000
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
999
1001
|
// Pattern B2 (cli half): per-participant rows expose status + error_message so
|
|
1000
1002
|
// agents can act on a failed run without re-fetching every participant.
|
|
1001
1003
|
const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
|
|
@@ -1025,18 +1027,18 @@ function buildStudyResultsEnvelope(study) {
|
|
|
1025
1027
|
participants: participantRows,
|
|
1026
1028
|
};
|
|
1027
1029
|
}
|
|
1028
|
-
export function formatStudyResults(study, json) {
|
|
1029
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
1030
|
+
export function formatStudyResults(study, participants, json) {
|
|
1031
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
1030
1032
|
if (inconsistency)
|
|
1031
1033
|
emitStatusInconsistencyWarning(inconsistency);
|
|
1032
1034
|
if (json) {
|
|
1033
1035
|
// preProjected: bypass leanJson so the stable envelope keeps documented
|
|
1034
1036
|
// empty defaults (sentiment: null, interview_answers[].answers: []) rather
|
|
1035
1037
|
// than having them stripped by the lean transform.
|
|
1036
|
-
console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
|
|
1038
|
+
console.log(jsonOutput(buildStudyResultsEnvelope(study, participants), { preProjected: true }));
|
|
1037
1039
|
return;
|
|
1038
1040
|
}
|
|
1039
|
-
const allParticipants = collectParticipants(study);
|
|
1041
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
1040
1042
|
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
1041
1043
|
// Header
|
|
1042
1044
|
console.log(`${study.name || "Untitled"} — Results`);
|
|
@@ -1102,8 +1104,8 @@ export function formatStudyResults(study, json) {
|
|
|
1102
1104
|
* per-participant {alias, status, sentiment, comment} row. Useful for agents that
|
|
1103
1105
|
* need to branch on outcome without paying for the full envelope.
|
|
1104
1106
|
*/
|
|
1105
|
-
export function buildStudyResultsSummary(study) {
|
|
1106
|
-
const allParticipants = collectParticipants(study);
|
|
1107
|
+
export function buildStudyResultsSummary(study, participants) {
|
|
1108
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
1107
1109
|
const studyAlias = study.id
|
|
1108
1110
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
1109
1111
|
: null;
|
|
@@ -1118,7 +1120,7 @@ export function buildStudyResultsSummary(study) {
|
|
|
1118
1120
|
}
|
|
1119
1121
|
}
|
|
1120
1122
|
const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
|
|
1121
|
-
const
|
|
1123
|
+
const participantRows = allParticipants.map((t) => ({
|
|
1122
1124
|
alias: t.alias || null,
|
|
1123
1125
|
name: t.name,
|
|
1124
1126
|
status: t.status,
|
|
@@ -1136,7 +1138,7 @@ export function buildStudyResultsSummary(study) {
|
|
|
1136
1138
|
completed_count: completedCount,
|
|
1137
1139
|
failed_count: failedCount,
|
|
1138
1140
|
sentiment,
|
|
1139
|
-
participants,
|
|
1141
|
+
participants: participantRows,
|
|
1140
1142
|
};
|
|
1141
1143
|
}
|
|
1142
1144
|
/**
|
|
@@ -1300,49 +1302,52 @@ export function buildParticipantSummary(participant) {
|
|
|
1300
1302
|
out.error_kind = String(participant.error_kind);
|
|
1301
1303
|
return out;
|
|
1302
1304
|
}
|
|
1303
|
-
function collectParticipants(
|
|
1304
|
-
const
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1305
|
+
function collectParticipants(participants, iterations) {
|
|
1306
|
+
const iterationLabels = new Map();
|
|
1307
|
+
for (const iter of iterations ?? []) {
|
|
1308
|
+
const id = iter.id ? String(iter.id) : "";
|
|
1309
|
+
if (id) {
|
|
1310
|
+
iterationLabels.set(id, String(iter.label || iter.name || "-"));
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
const rows = [];
|
|
1314
|
+
for (const participant of participants ?? []) {
|
|
1315
|
+
const t = participant;
|
|
1316
|
+
const profile = t.person;
|
|
1317
|
+
const interactions = Array.isArray(t.interactions) ? t.interactions : [];
|
|
1318
|
+
const sentimentCounts = {};
|
|
1319
|
+
for (const interaction of interactions) {
|
|
1320
|
+
const ix = interaction;
|
|
1321
|
+
const sentiment = ix.sentiment;
|
|
1322
|
+
if (sentiment?.label) {
|
|
1323
|
+
const label = String(sentiment.label);
|
|
1324
|
+
sentimentCounts[label] = (sentimentCounts[label] || 0) + 1;
|
|
1322
1325
|
}
|
|
1323
|
-
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
1324
|
-
const summary = t.participant_summary;
|
|
1325
|
-
const summarySentimentObj = summary?.sentiment;
|
|
1326
|
-
const id = String(t.id || "");
|
|
1327
|
-
participants.push({
|
|
1328
|
-
id,
|
|
1329
|
-
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
1330
|
-
alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
|
|
1331
|
-
iterationLabel: iterLabel,
|
|
1332
|
-
status: String(t.status || "-"),
|
|
1333
|
-
errorMessage: t.error_message ? String(t.error_message) : null,
|
|
1334
|
-
interactionCount: interactions.length,
|
|
1335
|
-
sentimentCounts,
|
|
1336
|
-
summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
|
|
1337
|
-
summaryComment: summary?.comment ? String(summary.comment) : null,
|
|
1338
|
-
interviewAnswers: answers.map((a) => ({
|
|
1339
|
-
questionId: String(a.question_id || ""),
|
|
1340
|
-
answer: a.answer,
|
|
1341
|
-
})),
|
|
1342
|
-
});
|
|
1343
1326
|
}
|
|
1327
|
+
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
1328
|
+
const summary = t.participant_summary;
|
|
1329
|
+
const summarySentimentObj = summary?.sentiment;
|
|
1330
|
+
const id = String(t.id || "");
|
|
1331
|
+
const iterationId = t.iteration_id ? String(t.iteration_id) : "";
|
|
1332
|
+
const iterationLabel = (iterationId && iterationLabels.get(iterationId)) || "-";
|
|
1333
|
+
rows.push({
|
|
1334
|
+
id,
|
|
1335
|
+
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
1336
|
+
alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
|
|
1337
|
+
iterationLabel,
|
|
1338
|
+
status: String(t.status || "-"),
|
|
1339
|
+
errorMessage: t.error_message ? String(t.error_message) : null,
|
|
1340
|
+
interactionCount: interactions.length,
|
|
1341
|
+
sentimentCounts,
|
|
1342
|
+
summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
|
|
1343
|
+
summaryComment: summary?.comment ? String(summary.comment) : null,
|
|
1344
|
+
interviewAnswers: answers.map((a) => ({
|
|
1345
|
+
questionId: String(a.question_id || ""),
|
|
1346
|
+
answer: a.answer,
|
|
1347
|
+
})),
|
|
1348
|
+
});
|
|
1344
1349
|
}
|
|
1345
|
-
return
|
|
1350
|
+
return rows;
|
|
1346
1351
|
}
|
|
1347
1352
|
function formatQuestionType(q) {
|
|
1348
1353
|
if (!q.type)
|
|
@@ -219,6 +219,7 @@ When in doubt: side-by-side comparison usually beats in-place edits. Ids are che
|
|
|
219
219
|
- **Chatbot auth drift**: tokens/sessions baked into \`--from-curl\` expire. If transcripts come back as identical short error strings, re-run \`chat_endpoint_test\` and refresh the curl spec.
|
|
220
220
|
- **401 surfaces as fake blocker**: an unauthenticated endpoint produces "participant got stuck on auth screen" — looks like a UX blocker but is config. Always confirm endpoint auth before reading transcripts as user-research data.
|
|
221
221
|
- **No per-page/per-timestamp scoping for media**: there's no "evaluate just slide 14" or "react to seconds 0-30" API. State the focus explicitly in the \`assignment\` text, or pre-stitch the artifact (e.g. replace one slide locally, upload as a new iteration).
|
|
222
|
+
- **\`study get --json\` participants live at the top level**, not nested under \`iterations[*].participants\`. The backend split made \`/studies/{id}\` lite (metadata + iteration shells, no participant graph) and added \`/studies/{id}/participants\`; the CLI joins them so \`study get --json\` carries a flat \`participants[]\` with \`iteration_id\` on each row. Read \`.participants[]\`, not \`.iterations[].participants[]\`.
|
|
222
223
|
|
|
223
224
|
## When in doubt
|
|
224
225
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Returns a flat list of participants for one study with an `iteration_id`
|
|
5
|
+
* discriminator on each row. Each row carries the rich per-participant graph
|
|
6
|
+
* (person, interactions[], participant_summary, interview_answers, …) that
|
|
7
|
+
* used to be embedded under `study.iterations[*].participants[*]` on the
|
|
8
|
+
* legacy `GET /studies/{id}` response.
|
|
9
|
+
*/
|
|
10
|
+
import type { ApiClient } from "./api-client.js";
|
|
11
|
+
import type { Participant } from "./types.js";
|
|
12
|
+
export interface StudyParticipant extends Participant {
|
|
13
|
+
person?: {
|
|
14
|
+
id?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
interactions?: unknown[];
|
|
18
|
+
participant_summary?: Record<string, unknown> | null;
|
|
19
|
+
interview_answers?: Array<{
|
|
20
|
+
question_id?: string;
|
|
21
|
+
answer?: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
participant_files?: unknown[];
|
|
24
|
+
participant_assignments?: unknown[];
|
|
25
|
+
conversation_id?: string | null;
|
|
26
|
+
error_message?: string | null;
|
|
27
|
+
error_kind?: string | null;
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
export declare function fetchStudyParticipants(client: ApiClient, studyId: string, opts?: {
|
|
31
|
+
timeout?: number;
|
|
32
|
+
}): Promise<StudyParticipant[]>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper for the post-split `GET /studies/{id}/participants` endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Returns a flat list of participants for one study with an `iteration_id`
|
|
5
|
+
* discriminator on each row. Each row carries the rich per-participant graph
|
|
6
|
+
* (person, interactions[], participant_summary, interview_answers, …) that
|
|
7
|
+
* used to be embedded under `study.iterations[*].participants[*]` on the
|
|
8
|
+
* legacy `GET /studies/{id}` response.
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchStudyParticipants(client, studyId, opts) {
|
|
11
|
+
return await client.get(`/studies/${studyId}/participants`, undefined, opts);
|
|
12
|
+
}
|
package/dist/lib/types.d.ts
CHANGED