@ishlabs/cli 0.17.7 → 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/README.md +54 -54
- package/dist/commands/ask.d.ts +4 -4
- package/dist/commands/ask.js +66 -66
- package/dist/commands/chat.js +10 -10
- package/dist/commands/config.js +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/iteration.js +57 -57
- package/dist/commands/mcp.d.ts +23 -0
- package/dist/commands/mcp.js +676 -0
- package/dist/commands/person.d.ts +5 -0
- package/dist/commands/{profile.js → person.js} +197 -162
- package/dist/commands/source.d.ts +6 -2
- package/dist/commands/source.js +35 -30
- package/dist/commands/study-analyze.d.ts +1 -1
- package/dist/commands/study-analyze.js +3 -3
- package/dist/commands/study-participant.d.ts +8 -0
- package/dist/commands/{study-tester.js → study-participant.js} +50 -50
- package/dist/commands/study-run.d.ts +6 -6
- package/dist/commands/study-run.js +341 -290
- package/dist/commands/study.js +106 -72
- package/dist/commands/workspace.js +13 -13
- package/dist/connect.js +5 -5
- package/dist/index.js +6 -4
- package/dist/lib/accessibility-profile.d.ts +1 -1
- package/dist/lib/accessibility-profile.js +1 -1
- package/dist/lib/alias-hydrate.js +4 -4
- package/dist/lib/alias-store.d.ts +5 -5
- package/dist/lib/alias-store.js +8 -8
- package/dist/lib/api-client.d.ts +1 -1
- package/dist/lib/api-client.js +1 -1
- package/dist/lib/billing.d.ts +11 -11
- package/dist/lib/billing.js +16 -16
- package/dist/lib/chat-endpoint-templates.js +1 -1
- package/dist/lib/command-helpers.d.ts +18 -18
- package/dist/lib/command-helpers.js +49 -37
- package/dist/lib/docs.js +570 -387
- package/dist/lib/enums.d.ts +2 -2
- package/dist/lib/enums.js +2 -2
- package/dist/lib/local-sim/browser.d.ts +1 -1
- package/dist/lib/local-sim/browser.js +1 -1
- package/dist/lib/local-sim/debug-report.d.ts +2 -2
- package/dist/lib/local-sim/debug-report.js +3 -3
- package/dist/lib/local-sim/loop.d.ts +5 -5
- package/dist/lib/local-sim/loop.js +38 -38
- package/dist/lib/local-sim/types.d.ts +12 -12
- package/dist/lib/mcp-clients.d.ts +51 -0
- package/dist/lib/mcp-clients.js +175 -0
- package/dist/lib/modality.d.ts +10 -10
- package/dist/lib/modality.js +46 -46
- package/dist/lib/output.d.ts +16 -15
- package/dist/lib/output.js +291 -226
- package/dist/lib/profile-sources.d.ts +64 -16
- package/dist/lib/profile-sources.js +91 -30
- package/dist/lib/skill-content.js +216 -168
- package/dist/lib/study-events.d.ts +3 -3
- package/dist/lib/study-events.js +1 -1
- package/dist/lib/study-inputs.d.ts +11 -1
- package/dist/lib/study-inputs.js +68 -17
- package/dist/lib/study-participants.d.ts +32 -0
- package/dist/lib/study-participants.js +12 -0
- package/dist/lib/types.d.ts +104 -34
- package/package.json +1 -1
- package/dist/commands/profile.d.ts +0 -5
- package/dist/commands/study-tester.d.ts +0 -8
package/dist/lib/output.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* for the full API response.
|
|
8
8
|
*/
|
|
9
9
|
import { ApiError } from "./api-client.js";
|
|
10
|
+
import { c } from "./colors.js";
|
|
10
11
|
import { deterministicAlias, getAliasMap, ALIAS_PREFIX } from "./alias-store.js";
|
|
11
12
|
// --- Lean JSON: strip noise for agent-friendly output ---
|
|
12
13
|
let _verbose = false;
|
|
@@ -117,15 +118,15 @@ const PAGINATION_KEYS = new Set(["items", "total", "returned", "limit", "offset"
|
|
|
117
118
|
// same shape leanJson strips elsewhere. These are load-bearing for agent
|
|
118
119
|
// follow-up calls and were forcing agents to pass `--verbose` (C5-Bug4).
|
|
119
120
|
const UUID_KEYS_TO_KEEP = new Set([
|
|
120
|
-
// ask: which variant the
|
|
121
|
+
// ask: which variant the participant picked — the load-bearing field for "who picked what".
|
|
121
122
|
"variant_pick_id",
|
|
122
123
|
]);
|
|
123
124
|
// Keys whose value must pass through leanJson untouched (no UUID stripping,
|
|
124
125
|
// no empty-array drop, no nested recursion). The entire shape is contract:
|
|
125
|
-
// every variant id key and every
|
|
126
|
+
// every variant id key and every participant id in its array is load-bearing,
|
|
126
127
|
// and unpicked variants must surface as `[]` rather than disappear.
|
|
127
128
|
const LEAN_PASSTHROUGH_KEYS = new Set([
|
|
128
|
-
// Pattern H: variant_id → [
|
|
129
|
+
// Pattern H: variant_id → [participant_id, ...] for drill-in subset discovery.
|
|
129
130
|
"pick_buckets",
|
|
130
131
|
// Pattern A: workspace cold-start fields. Agents need these without --verbose
|
|
131
132
|
// to spot a safe reuse target (has_headroom) and order workspaces by recency.
|
|
@@ -133,7 +134,7 @@ const LEAN_PASSTHROUGH_KEYS = new Set([
|
|
|
133
134
|
// child_counts is a dict — passthrough preserves the inner counts verbatim.
|
|
134
135
|
"study_count",
|
|
135
136
|
"ask_count",
|
|
136
|
-
"
|
|
137
|
+
"person_count",
|
|
137
138
|
"child_counts",
|
|
138
139
|
"has_headroom",
|
|
139
140
|
"last_activity_at",
|
|
@@ -491,7 +492,7 @@ export function outputError(err, json) {
|
|
|
491
492
|
...suggestions,
|
|
492
493
|
]));
|
|
493
494
|
const limitDetail = err.error_code === "usage_limit_reached" ? structuredDetail(err) : undefined;
|
|
494
|
-
// B7 / Pattern G: dispatch-attempt failures tag the seeded
|
|
495
|
+
// B7 / Pattern G: dispatch-attempt failures tag the seeded participants
|
|
495
496
|
// onto the thrown ApiError so the agent can resume without
|
|
496
497
|
// re-seeding (which would create duplicates). Surface alongside
|
|
497
498
|
// the error envelope so machine-readable consumers see them.
|
|
@@ -666,7 +667,7 @@ const WORKSPACE_DEFAULT_FIELDS = [
|
|
|
666
667
|
"created_at",
|
|
667
668
|
"study_count",
|
|
668
669
|
"ask_count",
|
|
669
|
-
"
|
|
670
|
+
"person_count",
|
|
670
671
|
"child_counts",
|
|
671
672
|
"has_headroom",
|
|
672
673
|
"last_activity_at",
|
|
@@ -701,13 +702,13 @@ export function formatWorkspaceList(workspaces, json) {
|
|
|
701
702
|
return;
|
|
702
703
|
}
|
|
703
704
|
const aliasMap = getAliasMap(ALIAS_PREFIX.workspace);
|
|
704
|
-
printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "
|
|
705
|
+
printTable(["#", "NAME", "ROOM", "STUDIES", "ASKS", "PARTICIPANTS", "LAST ACTIVITY"], workspaces.map((w) => [
|
|
705
706
|
aliasMap.get(String(w.id)) || String(w.id || ""),
|
|
706
707
|
String(w.name || ""),
|
|
707
708
|
formatHeadroom(w.has_headroom),
|
|
708
709
|
formatChildCount(w, "studies", "study_count"),
|
|
709
710
|
formatChildCount(w, "asks", "ask_count"),
|
|
710
|
-
formatChildCount(w, "
|
|
711
|
+
formatChildCount(w, "persons", "person_count"),
|
|
711
712
|
formatDate(w.last_activity_at),
|
|
712
713
|
]));
|
|
713
714
|
}
|
|
@@ -751,7 +752,7 @@ export function formatWorkspaceDetail(workspace, json, options = {}) {
|
|
|
751
752
|
Headroom: formatHeadroom(workspace.has_headroom),
|
|
752
753
|
Studies: formatChildCount(workspace, "studies", "study_count"),
|
|
753
754
|
Asks: formatChildCount(workspace, "asks", "ask_count"),
|
|
754
|
-
|
|
755
|
+
Participants: formatChildCount(workspace, "persons", "person_count"),
|
|
755
756
|
};
|
|
756
757
|
if (workspace.reused !== undefined)
|
|
757
758
|
display.Reused = workspace.reused ? "yes" : "no";
|
|
@@ -783,20 +784,20 @@ export function formatStudyList(studies, json) {
|
|
|
783
784
|
return;
|
|
784
785
|
}
|
|
785
786
|
const aliasMap = getAliasMap(ALIAS_PREFIX.study);
|
|
786
|
-
printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "
|
|
787
|
+
printTable(["#", "NAME", "MODALITY", "TYPE", "STATUS", "PARTICIPANTS"], studies.map((s) => [
|
|
787
788
|
aliasMap.get(String(s.id)) || String(s.id || ""),
|
|
788
789
|
String(s.name || ""),
|
|
789
790
|
String(s.modality || "-"),
|
|
790
791
|
String(s.content_type || "-"),
|
|
791
792
|
String(s.status || "draft"),
|
|
792
|
-
String(s.
|
|
793
|
+
String(s.participant_count ?? "0"),
|
|
793
794
|
]));
|
|
794
795
|
}
|
|
795
796
|
/**
|
|
796
797
|
* CLI-side sanity check for ALL-ISSUES Issue #2 / backend Pattern Bk2.
|
|
797
798
|
*
|
|
798
799
|
* Backend sometimes reports `status: "failed"` even when results are
|
|
799
|
-
* populated (
|
|
800
|
+
* populated (participants completed, interactions present). Until the backend
|
|
800
801
|
* root-cause is fixed, the CLI surfaces the inconsistency rather than
|
|
801
802
|
* letting agents trust a misleading status field:
|
|
802
803
|
* - JSON: adds a `status_inferred` field (e.g. `completed_with_errors`).
|
|
@@ -806,17 +807,17 @@ export function formatStudyList(studies, json) {
|
|
|
806
807
|
*
|
|
807
808
|
* Returns null when status is consistent; no warning emitted.
|
|
808
809
|
*/
|
|
809
|
-
function detectStudyStatusInconsistency(study) {
|
|
810
|
+
function detectStudyStatusInconsistency(study, participants) {
|
|
810
811
|
if (study.status !== "failed")
|
|
811
812
|
return null;
|
|
812
|
-
const
|
|
813
|
-
const completedCount =
|
|
814
|
-
const totalInteractions =
|
|
813
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
814
|
+
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
815
|
+
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
815
816
|
if (completedCount === 0 && totalInteractions === 0)
|
|
816
817
|
return null;
|
|
817
818
|
return {
|
|
818
819
|
inferred: "completed_with_errors",
|
|
819
|
-
reason: `${completedCount}/${
|
|
820
|
+
reason: `${completedCount}/${allParticipants.length} participants completed, ${totalInteractions} total interactions`,
|
|
820
821
|
};
|
|
821
822
|
}
|
|
822
823
|
function emitStatusInconsistencyWarning(inconsistency) {
|
|
@@ -824,14 +825,70 @@ function emitStatusInconsistencyWarning(inconsistency) {
|
|
|
824
825
|
`CLI inferring status_inferred="${inconsistency.inferred}". ` +
|
|
825
826
|
`Backend root-cause tracked as Issue #2 (Pattern Bk2).\n`);
|
|
826
827
|
}
|
|
827
|
-
|
|
828
|
-
|
|
828
|
+
/**
|
|
829
|
+
* Render an assignment's `steps` checklist and, once a run has graded them, the
|
|
830
|
+
* per-step `step_completion` rollup (pass rate + a few sample failure reasons).
|
|
831
|
+
* Both are printed indented under the assignment in `study get` human output;
|
|
832
|
+
* absent on draft studies and on modalities that don't support steps.
|
|
833
|
+
*/
|
|
834
|
+
function renderAssignmentSteps(a) {
|
|
835
|
+
const steps = Array.isArray(a.steps) ? a.steps : [];
|
|
836
|
+
const completion = Array.isArray(a.step_completion)
|
|
837
|
+
? a.step_completion
|
|
838
|
+
: [];
|
|
839
|
+
if (steps.length === 0 && completion.length === 0)
|
|
840
|
+
return;
|
|
841
|
+
// Index completion rows by step name so we can pair them with authored steps.
|
|
842
|
+
const byName = new Map();
|
|
843
|
+
for (const sc of completion) {
|
|
844
|
+
if (typeof sc.name === "string")
|
|
845
|
+
byName.set(sc.name, sc);
|
|
846
|
+
}
|
|
847
|
+
// Prefer the authored step order; fall back to completion rows if a study
|
|
848
|
+
// somehow returns completion without resolved steps.
|
|
849
|
+
const rows = steps.length > 0 ? steps : completion;
|
|
850
|
+
console.log(" Steps:");
|
|
851
|
+
for (let i = 0; i < rows.length; i++) {
|
|
852
|
+
const step = rows[i];
|
|
853
|
+
const name = String(step.name || "Untitled");
|
|
854
|
+
const sc = byName.get(name) ?? (steps.length === 0 ? step : undefined);
|
|
855
|
+
let line = ` ${i + 1}. ${name}`;
|
|
856
|
+
if (sc) {
|
|
857
|
+
const total = Number(sc.total ?? 0);
|
|
858
|
+
const passed = Number(sc.passed ?? 0);
|
|
859
|
+
const rate = sc.rate;
|
|
860
|
+
if (total === 0 || rate === null || rate === undefined) {
|
|
861
|
+
line += ` ${c.dim}— not yet graded${c.reset}`;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
const pct = Math.round(Number(rate) * 100);
|
|
865
|
+
const mark = passed === total ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`;
|
|
866
|
+
line += ` ${mark} ${passed}/${total} (${pct}%)`;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
console.log(line);
|
|
870
|
+
const desc = step.description ?? (sc ? sc.description : undefined);
|
|
871
|
+
if (typeof desc === "string" && desc.trim()) {
|
|
872
|
+
console.log(` ${c.dim}${truncate(desc, 72)}${c.reset}`);
|
|
873
|
+
}
|
|
874
|
+
const failures = sc && Array.isArray(sc.sample_failures) ? sc.sample_failures : [];
|
|
875
|
+
for (const f of failures) {
|
|
876
|
+
if (typeof f.reason === "string" && f.reason.trim()) {
|
|
877
|
+
console.log(` ${c.dim}✗ ${truncate(f.reason, 72)}${c.reset}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
export function formatStudyDetail(study, json, options = {}, participants) {
|
|
883
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
829
884
|
if (inconsistency)
|
|
830
885
|
emitStatusInconsistencyWarning(inconsistency);
|
|
831
886
|
if (json) {
|
|
832
|
-
const payload =
|
|
833
|
-
|
|
834
|
-
|
|
887
|
+
const payload = { ...study };
|
|
888
|
+
if (participants !== undefined)
|
|
889
|
+
payload.participants = participants;
|
|
890
|
+
if (inconsistency)
|
|
891
|
+
payload.status_inferred = inconsistency.inferred;
|
|
835
892
|
console.log(jsonOutput(payload, options));
|
|
836
893
|
return;
|
|
837
894
|
}
|
|
@@ -860,6 +917,7 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
860
917
|
if (a.instructions) {
|
|
861
918
|
console.log(` ${truncate(String(a.instructions), 80)}`);
|
|
862
919
|
}
|
|
920
|
+
renderAssignmentSteps(a);
|
|
863
921
|
}
|
|
864
922
|
}
|
|
865
923
|
// Interview Questions
|
|
@@ -872,12 +930,12 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
872
930
|
console.log(` ${i + 1}. ${q.question || "Untitled"} ${typeStr}`);
|
|
873
931
|
}
|
|
874
932
|
}
|
|
875
|
-
//
|
|
876
|
-
const
|
|
877
|
-
if (
|
|
878
|
-
console.log(`\
|
|
879
|
-
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"],
|
|
880
|
-
t.id ? deterministicAlias(ALIAS_PREFIX.
|
|
933
|
+
// Participants summary
|
|
934
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
935
|
+
if (allParticipants.length > 0) {
|
|
936
|
+
console.log(`\nParticipants (${allParticipants.length}):`);
|
|
937
|
+
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
|
|
938
|
+
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
881
939
|
t.name,
|
|
882
940
|
t.iterationLabel,
|
|
883
941
|
t.status,
|
|
@@ -890,16 +948,16 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
890
948
|
* study state — fields default to `null`, `0`, or `[]` when nothing has run.
|
|
891
949
|
* Agents can rely on the keys always being present (M4).
|
|
892
950
|
*/
|
|
893
|
-
function buildStudyResultsEnvelope(study) {
|
|
894
|
-
const
|
|
951
|
+
function buildStudyResultsEnvelope(study, participants) {
|
|
952
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
895
953
|
const studyAlias = study.id
|
|
896
954
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
897
955
|
: null;
|
|
898
|
-
const completedCount =
|
|
899
|
-
// Aggregate sentiment across all interactions on all
|
|
956
|
+
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
957
|
+
// Aggregate sentiment across all interactions on all participants.
|
|
900
958
|
const sentimentCounts = {};
|
|
901
959
|
let sentimentTotal = 0;
|
|
902
|
-
for (const t of
|
|
960
|
+
for (const t of allParticipants) {
|
|
903
961
|
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
904
962
|
sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
|
|
905
963
|
sentimentTotal += count;
|
|
@@ -912,18 +970,18 @@ function buildStudyResultsEnvelope(study) {
|
|
|
912
970
|
}
|
|
913
971
|
: null;
|
|
914
972
|
// Group interview answers by question for easy parsing. Each answer row
|
|
915
|
-
// includes the
|
|
916
|
-
// sentiment per answer without round-tripping `study
|
|
973
|
+
// includes the participant's session-level `sentiment` (M10) so agents can read
|
|
974
|
+
// sentiment per answer without round-tripping `study participant <id>` per row.
|
|
917
975
|
const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
|
|
918
976
|
const interviewAnswers = questions.map((q) => {
|
|
919
977
|
const qObj = q;
|
|
920
978
|
const answers = [];
|
|
921
|
-
for (const t of
|
|
979
|
+
for (const t of allParticipants) {
|
|
922
980
|
const a = t.interviewAnswers.find((x) => x.questionId === qObj.id);
|
|
923
981
|
if (a) {
|
|
924
982
|
answers.push({
|
|
925
|
-
|
|
926
|
-
|
|
983
|
+
participant_alias: t.alias || null,
|
|
984
|
+
participant_name: t.name,
|
|
927
985
|
iteration: t.iterationLabel,
|
|
928
986
|
answer: a.answer,
|
|
929
987
|
sentiment: t.summarySentiment,
|
|
@@ -939,11 +997,11 @@ function buildStudyResultsEnvelope(study) {
|
|
|
939
997
|
// CLI-side sanity check (Pattern E / Issue #2). Surface a status_inferred
|
|
940
998
|
// field when the backend reports failed-with-data; agents can branch on
|
|
941
999
|
// either the original status or status_inferred.
|
|
942
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
943
|
-
// Pattern B2 (cli half): per-
|
|
944
|
-
// agents can act on a failed run without re-fetching every
|
|
945
|
-
const failedCount =
|
|
946
|
-
const
|
|
1000
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
1001
|
+
// Pattern B2 (cli half): per-participant rows expose status + error_message so
|
|
1002
|
+
// agents can act on a failed run without re-fetching every participant.
|
|
1003
|
+
const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
|
|
1004
|
+
const participantRows = allParticipants.map((t) => ({
|
|
947
1005
|
alias: t.alias || null,
|
|
948
1006
|
name: t.name,
|
|
949
1007
|
iteration: t.iterationLabel,
|
|
@@ -961,33 +1019,33 @@ function buildStudyResultsEnvelope(study) {
|
|
|
961
1019
|
...(inconsistency && { status_inferred: inconsistency.inferred }),
|
|
962
1020
|
modality: study.modality || null,
|
|
963
1021
|
},
|
|
964
|
-
|
|
1022
|
+
participant_count: allParticipants.length,
|
|
965
1023
|
completed_count: completedCount,
|
|
966
1024
|
failed_count: failedCount,
|
|
967
1025
|
sentiment,
|
|
968
1026
|
interview_answers: interviewAnswers,
|
|
969
|
-
|
|
1027
|
+
participants: participantRows,
|
|
970
1028
|
};
|
|
971
1029
|
}
|
|
972
|
-
export function formatStudyResults(study, json) {
|
|
973
|
-
const inconsistency = detectStudyStatusInconsistency(study);
|
|
1030
|
+
export function formatStudyResults(study, participants, json) {
|
|
1031
|
+
const inconsistency = detectStudyStatusInconsistency(study, participants);
|
|
974
1032
|
if (inconsistency)
|
|
975
1033
|
emitStatusInconsistencyWarning(inconsistency);
|
|
976
1034
|
if (json) {
|
|
977
1035
|
// preProjected: bypass leanJson so the stable envelope keeps documented
|
|
978
1036
|
// empty defaults (sentiment: null, interview_answers[].answers: []) rather
|
|
979
1037
|
// than having them stripped by the lean transform.
|
|
980
|
-
console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
|
|
1038
|
+
console.log(jsonOutput(buildStudyResultsEnvelope(study, participants), { preProjected: true }));
|
|
981
1039
|
return;
|
|
982
1040
|
}
|
|
983
|
-
const
|
|
984
|
-
const totalInteractions =
|
|
1041
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
1042
|
+
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
985
1043
|
// Header
|
|
986
1044
|
console.log(`${study.name || "Untitled"} — Results`);
|
|
987
|
-
console.log(`${
|
|
1045
|
+
console.log(`${allParticipants.length} participant${allParticipants.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
|
|
988
1046
|
// Sentiment summary
|
|
989
1047
|
const totalSentiment = {};
|
|
990
|
-
for (const t of
|
|
1048
|
+
for (const t of allParticipants) {
|
|
991
1049
|
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
992
1050
|
totalSentiment[label] = (totalSentiment[label] || 0) + count;
|
|
993
1051
|
}
|
|
@@ -1002,7 +1060,7 @@ export function formatStudyResults(study, json) {
|
|
|
1002
1060
|
const qObj = q;
|
|
1003
1061
|
const typeStr = formatQuestionType(qObj);
|
|
1004
1062
|
console.log(`\n "${qObj.question}" ${typeStr}`);
|
|
1005
|
-
for (const t of
|
|
1063
|
+
for (const t of allParticipants) {
|
|
1006
1064
|
const answer = t.interviewAnswers.find((a) => a.questionId === qObj.id);
|
|
1007
1065
|
if (answer) {
|
|
1008
1066
|
const answerStr = typeof answer.answer === "string"
|
|
@@ -1013,13 +1071,13 @@ export function formatStudyResults(study, json) {
|
|
|
1013
1071
|
}
|
|
1014
1072
|
}
|
|
1015
1073
|
}
|
|
1016
|
-
//
|
|
1017
|
-
if (
|
|
1018
|
-
console.log("\
|
|
1019
|
-
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"],
|
|
1074
|
+
// Participants table
|
|
1075
|
+
if (allParticipants.length > 0) {
|
|
1076
|
+
console.log("\nParticipants:");
|
|
1077
|
+
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
|
|
1020
1078
|
const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
1021
1079
|
return [
|
|
1022
|
-
t.id ? deterministicAlias(ALIAS_PREFIX.
|
|
1080
|
+
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
1023
1081
|
t.name,
|
|
1024
1082
|
t.iterationLabel,
|
|
1025
1083
|
t.status,
|
|
@@ -1028,41 +1086,41 @@ export function formatStudyResults(study, json) {
|
|
|
1028
1086
|
];
|
|
1029
1087
|
}));
|
|
1030
1088
|
// Pattern B2: list any failure reasons under the table so agents see why
|
|
1031
|
-
// a run failed without drilling into `study
|
|
1032
|
-
const failedRows =
|
|
1089
|
+
// a run failed without drilling into `study participant <id>`.
|
|
1090
|
+
const failedRows = allParticipants.filter((t) => t.status.toLowerCase() === "failed" && t.errorMessage);
|
|
1033
1091
|
if (failedRows.length > 0) {
|
|
1034
|
-
console.log("\nFailed
|
|
1092
|
+
console.log("\nFailed participants:");
|
|
1035
1093
|
for (const t of failedRows) {
|
|
1036
|
-
const alias = t.id ? deterministicAlias(ALIAS_PREFIX.
|
|
1094
|
+
const alias = t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id;
|
|
1037
1095
|
console.log(` ${alias} (${t.name}): ${truncate(t.errorMessage, 200)}`);
|
|
1038
1096
|
}
|
|
1039
1097
|
}
|
|
1040
|
-
console.log("\nRun `ish
|
|
1098
|
+
console.log("\nRun `ish participant get <id> --json` for full interaction details.");
|
|
1041
1099
|
}
|
|
1042
1100
|
}
|
|
1043
1101
|
/**
|
|
1044
|
-
* `study results --summary` projection. Drops interview_answers + per-
|
|
1102
|
+
* `study results --summary` projection. Drops interview_answers + per-participant
|
|
1045
1103
|
* interaction breakdowns; keeps headline counters, sentiment histogram, and a
|
|
1046
|
-
* per-
|
|
1104
|
+
* per-participant {alias, status, sentiment, comment} row. Useful for agents that
|
|
1047
1105
|
* need to branch on outcome without paying for the full envelope.
|
|
1048
1106
|
*/
|
|
1049
|
-
export function buildStudyResultsSummary(study) {
|
|
1050
|
-
const
|
|
1107
|
+
export function buildStudyResultsSummary(study, participants) {
|
|
1108
|
+
const allParticipants = collectParticipants(participants, Array.isArray(study.iterations) ? study.iterations : []);
|
|
1051
1109
|
const studyAlias = study.id
|
|
1052
1110
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
1053
1111
|
: null;
|
|
1054
|
-
const completedCount =
|
|
1055
|
-
const failedCount =
|
|
1112
|
+
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
1113
|
+
const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
|
|
1056
1114
|
const sentimentCounts = {};
|
|
1057
1115
|
let sentimentTotal = 0;
|
|
1058
|
-
for (const t of
|
|
1116
|
+
for (const t of allParticipants) {
|
|
1059
1117
|
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
1060
1118
|
sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
|
|
1061
1119
|
sentimentTotal += count;
|
|
1062
1120
|
}
|
|
1063
1121
|
}
|
|
1064
1122
|
const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
|
|
1065
|
-
const
|
|
1123
|
+
const participantRows = allParticipants.map((t) => ({
|
|
1066
1124
|
alias: t.alias || null,
|
|
1067
1125
|
name: t.name,
|
|
1068
1126
|
status: t.status,
|
|
@@ -1076,27 +1134,27 @@ export function buildStudyResultsSummary(study) {
|
|
|
1076
1134
|
name: study.name || null,
|
|
1077
1135
|
modality: study.modality || null,
|
|
1078
1136
|
},
|
|
1079
|
-
|
|
1137
|
+
participant_count: allParticipants.length,
|
|
1080
1138
|
completed_count: completedCount,
|
|
1081
1139
|
failed_count: failedCount,
|
|
1082
1140
|
sentiment,
|
|
1083
|
-
|
|
1141
|
+
participants: participantRows,
|
|
1084
1142
|
};
|
|
1085
1143
|
}
|
|
1086
1144
|
/**
|
|
1087
|
-
* `study results --transcript <
|
|
1145
|
+
* `study results --transcript <participant_id>` projection. Mirrors the schema
|
|
1088
1146
|
* MCP's `get_chat_transcript` returns (`src/ish_mcp/projections.py:
|
|
1089
1147
|
* build_chat_transcript`) so callers see the same shape regardless of
|
|
1090
|
-
* surface.
|
|
1148
|
+
* surface. Participant turns whose action carries no text (e.g. select_option)
|
|
1091
1149
|
* surface `text: null`; intent lives on `action_type` + `option_label`.
|
|
1092
1150
|
* Bot turns with a `bot_reply.failure` block surface `failure` and
|
|
1093
1151
|
* `text: null` and don't count toward `unique_bot_replies`.
|
|
1094
1152
|
*/
|
|
1095
|
-
export function buildChatTranscript(
|
|
1096
|
-
const id = String(
|
|
1097
|
-
const alias = id ? deterministicAlias(ALIAS_PREFIX.
|
|
1098
|
-
const profile =
|
|
1099
|
-
const interactions = Array.isArray(
|
|
1153
|
+
export function buildChatTranscript(participant) {
|
|
1154
|
+
const id = String(participant.id || "");
|
|
1155
|
+
const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
|
|
1156
|
+
const profile = participant.person;
|
|
1157
|
+
const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
|
|
1100
1158
|
// Sort by timestamp then created_at so agent doesn't need to re-sort.
|
|
1101
1159
|
const sorted = [...interactions].sort((a, b) => {
|
|
1102
1160
|
const aIx = a;
|
|
@@ -1115,14 +1173,14 @@ export function buildChatTranscript(tester) {
|
|
|
1115
1173
|
const uniqueBotReplies = new Set();
|
|
1116
1174
|
for (const interaction of sorted) {
|
|
1117
1175
|
const ix = interaction;
|
|
1118
|
-
//
|
|
1176
|
+
// Participant turn — derive role/action from the interaction itself.
|
|
1119
1177
|
// Backend shape (post a880eba rename):
|
|
1120
1178
|
// ix.actor in {"ai", "human", "user"} (user is the actual end-user;
|
|
1121
1179
|
// we don't surface those in the transcript).
|
|
1122
1180
|
// ix.actions: [{action_type, data: {type, turn_index, text?, wire_text?,
|
|
1123
1181
|
// option_label?, said_instead?, ...}}]
|
|
1124
1182
|
// ix.bot_reply: {text?, failure?}
|
|
1125
|
-
// The
|
|
1183
|
+
// The participant's actual message text is nested under `action.data` —
|
|
1126
1184
|
// earlier versions of this builder read off the action top-level
|
|
1127
1185
|
// (`action.text`, `action.type`), which silently produced
|
|
1128
1186
|
// `text: null` on every turn (PC-C3 finding #3).
|
|
@@ -1157,7 +1215,7 @@ export function buildChatTranscript(tester) {
|
|
|
1157
1215
|
}
|
|
1158
1216
|
const actor = String(ix.actor ?? ix.interaction_type ?? "");
|
|
1159
1217
|
if (actor === "ai" || actor === "human") {
|
|
1160
|
-
// Resolve the
|
|
1218
|
+
// Resolve the participant's literal text from action.data, preferring
|
|
1161
1219
|
// the canonical wire_text the backend exposes for every action
|
|
1162
1220
|
// shape (send_text, select_option, ignore_offered, …) so the
|
|
1163
1221
|
// transcript carries the actual content on every turn — D2.
|
|
@@ -1188,7 +1246,7 @@ export function buildChatTranscript(tester) {
|
|
|
1188
1246
|
: null));
|
|
1189
1247
|
const sentimentObj = ix.sentiment;
|
|
1190
1248
|
transcript.push({
|
|
1191
|
-
role: "
|
|
1249
|
+
role: "participant",
|
|
1192
1250
|
text,
|
|
1193
1251
|
turn_index: turnIndex,
|
|
1194
1252
|
action_type: actionType,
|
|
@@ -1197,15 +1255,15 @@ export function buildChatTranscript(tester) {
|
|
|
1197
1255
|
});
|
|
1198
1256
|
}
|
|
1199
1257
|
}
|
|
1200
|
-
const summary =
|
|
1258
|
+
const summary = participant.participant_summary;
|
|
1201
1259
|
return {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
instance_name:
|
|
1260
|
+
participant_id: id || null,
|
|
1261
|
+
participant_alias: alias,
|
|
1262
|
+
instance_name: participant.instance_name ?? null,
|
|
1205
1263
|
modality: "chat",
|
|
1206
1264
|
transcript,
|
|
1207
1265
|
unique_bot_replies: uniqueBotReplies.size,
|
|
1208
|
-
|
|
1266
|
+
participant_summary: summary
|
|
1209
1267
|
? {
|
|
1210
1268
|
comment: summary.comment ?? null,
|
|
1211
1269
|
sentiment: summary.sentiment ?? null,
|
|
@@ -1215,78 +1273,81 @@ export function buildChatTranscript(tester) {
|
|
|
1215
1273
|
};
|
|
1216
1274
|
}
|
|
1217
1275
|
/**
|
|
1218
|
-
* `study
|
|
1276
|
+
* `study participant --summary` projection. Drops the action timeline; keeps the
|
|
1219
1277
|
* headline (alias, status, sentiment, comment, error_message). Useful for
|
|
1220
|
-
* the common "did this
|
|
1278
|
+
* the common "did this participant finish, what did they say" check that's
|
|
1221
1279
|
* currently buried under the full interactions array.
|
|
1222
1280
|
*/
|
|
1223
|
-
export function
|
|
1224
|
-
const id = String(
|
|
1225
|
-
const alias = id ? deterministicAlias(ALIAS_PREFIX.
|
|
1226
|
-
const profile =
|
|
1227
|
-
const summary =
|
|
1281
|
+
export function buildParticipantSummary(participant) {
|
|
1282
|
+
const id = String(participant.id || "");
|
|
1283
|
+
const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
|
|
1284
|
+
const profile = participant.person;
|
|
1285
|
+
const summary = participant.participant_summary;
|
|
1228
1286
|
const summarySentiment = summary?.sentiment;
|
|
1229
|
-
const interactions = Array.isArray(
|
|
1287
|
+
const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
|
|
1230
1288
|
const out = {
|
|
1231
|
-
|
|
1289
|
+
participant: {
|
|
1232
1290
|
alias,
|
|
1233
|
-
name: profile?.name ??
|
|
1234
|
-
iteration_id:
|
|
1235
|
-
status:
|
|
1291
|
+
name: profile?.name ?? participant.instance_name ?? null,
|
|
1292
|
+
iteration_id: participant.iteration_id ?? null,
|
|
1293
|
+
status: participant.status ?? null,
|
|
1236
1294
|
},
|
|
1237
1295
|
interaction_count: interactions.length,
|
|
1238
1296
|
sentiment: summarySentiment?.label ?? null,
|
|
1239
1297
|
comment: summary?.comment ?? null,
|
|
1240
1298
|
};
|
|
1241
|
-
if (
|
|
1242
|
-
out.error_message = String(
|
|
1243
|
-
if (
|
|
1244
|
-
out.error_kind = String(
|
|
1299
|
+
if (participant.error_message)
|
|
1300
|
+
out.error_message = String(participant.error_message);
|
|
1301
|
+
if (participant.error_kind)
|
|
1302
|
+
out.error_kind = String(participant.error_kind);
|
|
1245
1303
|
return out;
|
|
1246
1304
|
}
|
|
1247
|
-
function
|
|
1248
|
-
const
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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;
|
|
1266
1325
|
}
|
|
1267
|
-
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
1268
|
-
const summary = t.tester_summary;
|
|
1269
|
-
const summarySentimentObj = summary?.sentiment;
|
|
1270
|
-
const id = String(t.id || "");
|
|
1271
|
-
testers.push({
|
|
1272
|
-
id,
|
|
1273
|
-
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
1274
|
-
alias: id ? deterministicAlias(ALIAS_PREFIX.tester, id) : "",
|
|
1275
|
-
iterationLabel: iterLabel,
|
|
1276
|
-
status: String(t.status || "-"),
|
|
1277
|
-
errorMessage: t.error_message ? String(t.error_message) : null,
|
|
1278
|
-
interactionCount: interactions.length,
|
|
1279
|
-
sentimentCounts,
|
|
1280
|
-
summarySentiment: summarySentimentObj?.label ? String(summarySentimentObj.label) : null,
|
|
1281
|
-
summaryComment: summary?.comment ? String(summary.comment) : null,
|
|
1282
|
-
interviewAnswers: answers.map((a) => ({
|
|
1283
|
-
questionId: String(a.question_id || ""),
|
|
1284
|
-
answer: a.answer,
|
|
1285
|
-
})),
|
|
1286
|
-
});
|
|
1287
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
|
+
});
|
|
1288
1349
|
}
|
|
1289
|
-
return
|
|
1350
|
+
return rows;
|
|
1290
1351
|
}
|
|
1291
1352
|
function formatQuestionType(q) {
|
|
1292
1353
|
if (!q.type)
|
|
@@ -1314,26 +1375,26 @@ export function formatIterationList(iterations, json) {
|
|
|
1314
1375
|
return;
|
|
1315
1376
|
}
|
|
1316
1377
|
const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
|
|
1317
|
-
printTable(["#", "LABEL", "NAME", "
|
|
1318
|
-
const
|
|
1378
|
+
printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
|
|
1379
|
+
const participants = Array.isArray(it.participants) ? it.participants.length : 0;
|
|
1319
1380
|
return [
|
|
1320
1381
|
aliasMap.get(String(it.id)) || String(it.id || ""),
|
|
1321
1382
|
String(it.label || "-"),
|
|
1322
1383
|
String(it.name || ""),
|
|
1323
|
-
String(
|
|
1384
|
+
String(participants),
|
|
1324
1385
|
formatDate(it.created_at),
|
|
1325
1386
|
];
|
|
1326
1387
|
}));
|
|
1327
1388
|
}
|
|
1328
|
-
// ---
|
|
1329
|
-
export function
|
|
1389
|
+
// --- Participant formatting ---
|
|
1390
|
+
export function formatParticipantDetail(participant, json) {
|
|
1330
1391
|
if (json) {
|
|
1331
|
-
console.log(jsonOutput(
|
|
1392
|
+
console.log(jsonOutput(participant));
|
|
1332
1393
|
return;
|
|
1333
1394
|
}
|
|
1334
|
-
const profile =
|
|
1395
|
+
const profile = participant.person;
|
|
1335
1396
|
const profileName = profile?.name ? String(profile.name) : "Unknown";
|
|
1336
|
-
const interactions = Array.isArray(
|
|
1397
|
+
const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
|
|
1337
1398
|
// Count sentiments
|
|
1338
1399
|
const sentimentCounts = {};
|
|
1339
1400
|
for (const interaction of interactions) {
|
|
@@ -1345,17 +1406,17 @@ export function formatTesterDetail(tester, json) {
|
|
|
1345
1406
|
}
|
|
1346
1407
|
}
|
|
1347
1408
|
const sentimentParts = Object.entries(sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
1348
|
-
const status = String(
|
|
1349
|
-
const errorMessage =
|
|
1409
|
+
const status = String(participant.status || "-");
|
|
1410
|
+
const errorMessage = participant.error_message ? String(participant.error_message) : null;
|
|
1350
1411
|
const display = {
|
|
1351
|
-
ID:
|
|
1412
|
+
ID: participant.id || "-",
|
|
1352
1413
|
Profile: profileName,
|
|
1353
1414
|
Status: status,
|
|
1354
1415
|
...(errorMessage && status.toLowerCase() === "failed" && {
|
|
1355
1416
|
Error: errorMessage,
|
|
1356
1417
|
}),
|
|
1357
|
-
Platform:
|
|
1358
|
-
Language:
|
|
1418
|
+
Platform: participant.platform || "-",
|
|
1419
|
+
Language: participant.language || "-",
|
|
1359
1420
|
Interactions: `${interactions.length} interactions`,
|
|
1360
1421
|
...(sentimentParts.length > 0 && {
|
|
1361
1422
|
Sentiment: sentimentParts.join(", "),
|
|
@@ -1363,8 +1424,8 @@ export function formatTesterDetail(tester, json) {
|
|
|
1363
1424
|
};
|
|
1364
1425
|
printKeyValue(display);
|
|
1365
1426
|
}
|
|
1366
|
-
// ---
|
|
1367
|
-
export function
|
|
1427
|
+
// --- Participant Profile formatting ---
|
|
1428
|
+
export function formatPersonList(profiles, json, limit) {
|
|
1368
1429
|
// The API may return { items: [...], total, limit, offset } or a flat array.
|
|
1369
1430
|
const wrapper = profiles;
|
|
1370
1431
|
const wasWrapper = !Array.isArray(profiles)
|
|
@@ -1377,7 +1438,7 @@ export function formatTesterProfileList(profiles, json, limit) {
|
|
|
1377
1438
|
: [];
|
|
1378
1439
|
// Client-side limit (server may not enforce it)
|
|
1379
1440
|
const list = limit ? fullList.slice(0, limit) : fullList;
|
|
1380
|
-
injectAliases(list, ALIAS_PREFIX.
|
|
1441
|
+
injectAliases(list, ALIAS_PREFIX.person);
|
|
1381
1442
|
if (json) {
|
|
1382
1443
|
// Pass through server-provided pagination when present; otherwise synthesize.
|
|
1383
1444
|
const existing = wasWrapper ? wrapper : undefined;
|
|
@@ -1385,7 +1446,7 @@ export function formatTesterProfileList(profiles, json, limit) {
|
|
|
1385
1446
|
return;
|
|
1386
1447
|
}
|
|
1387
1448
|
if (list.length === 0) {
|
|
1388
|
-
console.log("No
|
|
1449
|
+
console.log("No participant profiles.");
|
|
1389
1450
|
return;
|
|
1390
1451
|
}
|
|
1391
1452
|
printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
@@ -1400,31 +1461,35 @@ export function formatTesterProfileList(profiles, json, limit) {
|
|
|
1400
1461
|
console.log(`\n Showing ${list.length} of ${fullList.length} profiles. Use --limit and --offset for more.`);
|
|
1401
1462
|
}
|
|
1402
1463
|
}
|
|
1403
|
-
// ---
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1464
|
+
// --- Participant attachment formatting ---
|
|
1465
|
+
//
|
|
1466
|
+
// `formatAttachment` is the canonical printer. `formatPersonSource` is
|
|
1467
|
+
// an alias for callers that reference the person-source name.
|
|
1468
|
+
export function formatAttachment(attachment, json) {
|
|
1469
|
+
if (attachment.id) {
|
|
1470
|
+
attachment.alias = deterministicAlias(ALIAS_PREFIX.personSource, String(attachment.id));
|
|
1407
1471
|
}
|
|
1408
1472
|
if (json) {
|
|
1409
|
-
console.log(jsonOutput(
|
|
1473
|
+
console.log(jsonOutput(attachment));
|
|
1410
1474
|
return;
|
|
1411
1475
|
}
|
|
1412
1476
|
const display = {
|
|
1413
|
-
Alias:
|
|
1414
|
-
File:
|
|
1415
|
-
Kind:
|
|
1416
|
-
Status:
|
|
1417
|
-
"Content-Type":
|
|
1477
|
+
Alias: attachment.alias || attachment.id || "-",
|
|
1478
|
+
File: attachment.file_name || attachment.original_filename || "-",
|
|
1479
|
+
Kind: attachment.kind || "-",
|
|
1480
|
+
Status: attachment.status || "-",
|
|
1481
|
+
"Content-Type": attachment.content_type || "-",
|
|
1418
1482
|
};
|
|
1419
|
-
if (
|
|
1420
|
-
display["Text length"] =
|
|
1483
|
+
if (attachment.extracted_text_length != null) {
|
|
1484
|
+
display["Text length"] = attachment.extracted_text_length;
|
|
1421
1485
|
}
|
|
1422
|
-
if (
|
|
1423
|
-
display.Error =
|
|
1486
|
+
if (attachment.error) {
|
|
1487
|
+
display.Error = attachment.error;
|
|
1424
1488
|
}
|
|
1425
1489
|
printKeyValue(display);
|
|
1426
1490
|
}
|
|
1427
|
-
|
|
1491
|
+
export const formatPersonSource = formatAttachment;
|
|
1492
|
+
// --- Generated profile list (returned by /people/generate) ---
|
|
1428
1493
|
export function formatGeneratedProfileList(profiles, json) {
|
|
1429
1494
|
const list = Array.isArray(profiles) ? profiles : [];
|
|
1430
1495
|
if (list.length === 0) {
|
|
@@ -1434,7 +1499,7 @@ export function formatGeneratedProfileList(profiles, json) {
|
|
|
1434
1499
|
console.log("No profiles generated.");
|
|
1435
1500
|
return;
|
|
1436
1501
|
}
|
|
1437
|
-
injectAliases(list, ALIAS_PREFIX.
|
|
1502
|
+
injectAliases(list, ALIAS_PREFIX.person);
|
|
1438
1503
|
if (json) {
|
|
1439
1504
|
console.log(jsonOutput(list));
|
|
1440
1505
|
return;
|
|
@@ -1457,26 +1522,26 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1457
1522
|
console.log("No simulations found.");
|
|
1458
1523
|
return;
|
|
1459
1524
|
}
|
|
1460
|
-
injectAliases(results, ALIAS_PREFIX.
|
|
1525
|
+
injectAliases(results, ALIAS_PREFIX.participant);
|
|
1461
1526
|
if (json) {
|
|
1462
1527
|
console.log(jsonOutput(results));
|
|
1463
1528
|
return;
|
|
1464
1529
|
}
|
|
1465
|
-
const aliasMap = getAliasMap(ALIAS_PREFIX.
|
|
1530
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
|
|
1466
1531
|
const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
|
|
1467
|
-
printTable(["#", "
|
|
1468
|
-
const id = String(r.id || r.
|
|
1532
|
+
printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
|
|
1533
|
+
const id = String(r.id || r.participant_id || "");
|
|
1469
1534
|
return [
|
|
1470
1535
|
aliasMap.get(id) || id,
|
|
1471
|
-
String(r.
|
|
1536
|
+
String(r.participant_name || "Unknown"),
|
|
1472
1537
|
String(r.status || "UNKNOWN"),
|
|
1473
1538
|
String(r.interaction_count ?? "0"),
|
|
1474
1539
|
];
|
|
1475
1540
|
}));
|
|
1476
|
-
// Pattern A (cli half): list per-
|
|
1477
|
-
// agents see why a simulation failed without re-fetching every
|
|
1541
|
+
// Pattern A (cli half): list per-participant error_message under the table so
|
|
1542
|
+
// agents see why a simulation failed without re-fetching every participant.
|
|
1478
1543
|
// Truncate to 200 chars; full text is available via --json or
|
|
1479
|
-
// `ish study
|
|
1544
|
+
// `ish study participant get <id>`.
|
|
1480
1545
|
const failedRows = results.filter((r) => {
|
|
1481
1546
|
const status = String(r.status || "").toLowerCase();
|
|
1482
1547
|
return (status === "failed" || status === "errored") && r.error_message;
|
|
@@ -1484,9 +1549,9 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1484
1549
|
if (failedRows.length > 0) {
|
|
1485
1550
|
console.log("\nFailed simulations:");
|
|
1486
1551
|
for (const r of failedRows) {
|
|
1487
|
-
const id = String(r.id || r.
|
|
1552
|
+
const id = String(r.id || r.participant_id || "");
|
|
1488
1553
|
const alias = aliasMap.get(id) || id;
|
|
1489
|
-
const name = String(r.
|
|
1554
|
+
const name = String(r.participant_name || "Unknown");
|
|
1490
1555
|
console.log(` ${alias} (${name}): ${truncate(String(r.error_message), 200)}`);
|
|
1491
1556
|
}
|
|
1492
1557
|
}
|
|
@@ -1509,11 +1574,11 @@ export function formatAskList(asks, json) {
|
|
|
1509
1574
|
return;
|
|
1510
1575
|
}
|
|
1511
1576
|
const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
|
|
1512
|
-
printTable(["#", "NAME", "STATUS", "
|
|
1577
|
+
printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
|
|
1513
1578
|
aliasMap.get(String(a.id)) || String(a.id || ""),
|
|
1514
1579
|
String(a.name || ""),
|
|
1515
1580
|
String(a.status || "-"),
|
|
1516
|
-
String(a.
|
|
1581
|
+
String(a.participant_count ?? "0"),
|
|
1517
1582
|
String(a.round_count ?? "0"),
|
|
1518
1583
|
formatDate(a.last_round_at),
|
|
1519
1584
|
a.is_archived ? "yes" : "no",
|
|
@@ -1550,7 +1615,7 @@ function denormalizeRoundCounts(round) {
|
|
|
1550
1615
|
* Layer denormalized counts onto an ask detail so agents reading
|
|
1551
1616
|
* `ask get`, `ask create --wait`, `ask run --wait`, etc. don't need to
|
|
1552
1617
|
* count nested arrays:
|
|
1553
|
-
* -
|
|
1618
|
+
* - participants_count: ask.participants.length
|
|
1554
1619
|
* - responses_total: sum across rounds (only when > 0)
|
|
1555
1620
|
* - responses_complete: sum across rounds
|
|
1556
1621
|
* - responses_errored: sum across rounds (only when > 0)
|
|
@@ -1558,9 +1623,9 @@ function denormalizeRoundCounts(round) {
|
|
|
1558
1623
|
*/
|
|
1559
1624
|
function denormalizeAskCounts(ask) {
|
|
1560
1625
|
const enriched = { ...ask };
|
|
1561
|
-
const
|
|
1562
|
-
if (
|
|
1563
|
-
enriched.
|
|
1626
|
+
const participants = Array.isArray(ask.participants) ? ask.participants : null;
|
|
1627
|
+
if (participants)
|
|
1628
|
+
enriched.participants_count = participants.length;
|
|
1564
1629
|
const rounds = Array.isArray(ask.rounds) ? ask.rounds : null;
|
|
1565
1630
|
if (rounds) {
|
|
1566
1631
|
let total = 0;
|
|
@@ -1597,22 +1662,22 @@ export function formatAskDetail(ask, json) {
|
|
|
1597
1662
|
meta.push("archived");
|
|
1598
1663
|
meta.push(formatDate(ask.created_at));
|
|
1599
1664
|
console.log(meta.join(" · "));
|
|
1600
|
-
const
|
|
1601
|
-
console.log(`\
|
|
1602
|
-
if (
|
|
1603
|
-
const rows =
|
|
1665
|
+
const participants = Array.isArray(ask.participants) ? ask.participants : [];
|
|
1666
|
+
console.log(`\nParticipants (${participants.length}):`);
|
|
1667
|
+
if (participants.length > 0) {
|
|
1668
|
+
const rows = participants.slice(0, 20).map((t) => {
|
|
1604
1669
|
const obj = t;
|
|
1605
|
-
const profile = obj.
|
|
1670
|
+
const profile = obj.person;
|
|
1606
1671
|
const name = String(profile?.name || obj.instance_name || "Unknown");
|
|
1607
1672
|
return [
|
|
1608
|
-
obj.id ? deterministicAlias(ALIAS_PREFIX.
|
|
1673
|
+
obj.id ? deterministicAlias(ALIAS_PREFIX.participant, String(obj.id)) : "-",
|
|
1609
1674
|
name,
|
|
1610
1675
|
String(obj.status || "-"),
|
|
1611
1676
|
];
|
|
1612
1677
|
});
|
|
1613
1678
|
printTable(["#", "NAME", "STATUS"], rows);
|
|
1614
|
-
if (
|
|
1615
|
-
console.log(` … and ${
|
|
1679
|
+
if (participants.length > 20)
|
|
1680
|
+
console.log(` … and ${participants.length - 20} more`);
|
|
1616
1681
|
}
|
|
1617
1682
|
const rounds = Array.isArray(ask.rounds) ? ask.rounds : [];
|
|
1618
1683
|
if (rounds.length > 0) {
|
|
@@ -1709,18 +1774,18 @@ function computeVariantStats(round) {
|
|
|
1709
1774
|
const ERROR_RATE_REFUSE_THRESHOLD = 0.5;
|
|
1710
1775
|
const N_HIGH_CONFIDENCE_FLOOR = 10;
|
|
1711
1776
|
const N_MEDIUM_CONFIDENCE_FLOOR = 3;
|
|
1712
|
-
// When
|
|
1777
|
+
// When person and person_snapshot share all overlapping fields
|
|
1713
1778
|
// (the common case — snapshot only diverges if the profile was edited after
|
|
1714
1779
|
// dispatch), drop the redundant content from the snapshot and keep only the
|
|
1715
|
-
// snapshot-specific metadata. Saves ~500-1000 bytes per
|
|
1716
|
-
function
|
|
1717
|
-
const tp =
|
|
1718
|
-
const tps =
|
|
1780
|
+
// snapshot-specific metadata. Saves ~500-1000 bytes per participant in JSON output.
|
|
1781
|
+
function dedupeParticipantSnapshot(participant) {
|
|
1782
|
+
const tp = participant.person;
|
|
1783
|
+
const tps = participant.person_snapshot;
|
|
1719
1784
|
if (!tp || !tps)
|
|
1720
|
-
return
|
|
1785
|
+
return participant;
|
|
1721
1786
|
const shared = Object.keys(tps).filter((k) => k in tp);
|
|
1722
1787
|
if (shared.length === 0)
|
|
1723
|
-
return
|
|
1788
|
+
return participant;
|
|
1724
1789
|
const isEmpty = (v) => {
|
|
1725
1790
|
if (v === null || v === undefined)
|
|
1726
1791
|
return true;
|
|
@@ -1738,15 +1803,15 @@ function dedupeTesterSnapshot(tester) {
|
|
|
1738
1803
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1739
1804
|
});
|
|
1740
1805
|
if (!allMatch)
|
|
1741
|
-
return
|
|
1806
|
+
return participant;
|
|
1742
1807
|
const snapshotOnly = {};
|
|
1743
1808
|
for (const k of Object.keys(tps)) {
|
|
1744
1809
|
if (!(k in tp))
|
|
1745
1810
|
snapshotOnly[k] = tps[k];
|
|
1746
1811
|
}
|
|
1747
1812
|
return {
|
|
1748
|
-
...
|
|
1749
|
-
|
|
1813
|
+
...participant,
|
|
1814
|
+
person_snapshot: { ...snapshotOnly, _matches_person: true },
|
|
1750
1815
|
};
|
|
1751
1816
|
}
|
|
1752
1817
|
// Shape per-variant stats into a machine-readable aggregates object so agents
|
|
@@ -1796,11 +1861,11 @@ function buildAggregates(round, stats) {
|
|
|
1796
1861
|
}
|
|
1797
1862
|
out.picks = picks;
|
|
1798
1863
|
// Pattern H: pick_buckets keyed by variant id (the value `add_ask_round
|
|
1799
|
-
// --subset-variant` accepts) →
|
|
1864
|
+
// --subset-variant` accepts) → participant_ids that picked it. Pre-seed with
|
|
1800
1865
|
// every declared variant id so unpicked variants surface as empty
|
|
1801
1866
|
// arrays. Only completed responses with a resolvable variant_pick_id
|
|
1802
1867
|
// contribute; an errored response carrying a stale variant_pick_id
|
|
1803
|
-
// would otherwise drag a
|
|
1868
|
+
// would otherwise drag a participant into a drill-in subset whose pick
|
|
1804
1869
|
// we can't trust.
|
|
1805
1870
|
const variants = Array.isArray(round.variants) ? round.variants : [];
|
|
1806
1871
|
const variantIdSet = new Set();
|
|
@@ -1818,7 +1883,7 @@ function buildAggregates(round, stats) {
|
|
|
1818
1883
|
if (resp.status !== "completed")
|
|
1819
1884
|
continue;
|
|
1820
1885
|
const vpid = resp.variant_pick_id;
|
|
1821
|
-
const tid = resp.
|
|
1886
|
+
const tid = resp.participant_id;
|
|
1822
1887
|
if (typeof vpid === "string"
|
|
1823
1888
|
&& variantIdSet.has(vpid)
|
|
1824
1889
|
&& typeof tid === "string"
|
|
@@ -1831,7 +1896,7 @@ function buildAggregates(round, stats) {
|
|
|
1831
1896
|
if (topCount > 0) {
|
|
1832
1897
|
// Refuse the winner when more than half of dispatched responses errored.
|
|
1833
1898
|
// Calling A or B with a 4/5 failure rate would mislead the agent into
|
|
1834
|
-
// treating one
|
|
1899
|
+
// treating one participant's pick as a verdict.
|
|
1835
1900
|
if (dispatchedTotal > 0
|
|
1836
1901
|
&& erroredTotal / dispatchedTotal > ERROR_RATE_REFUSE_THRESHOLD) {
|
|
1837
1902
|
out.winner = {
|
|
@@ -1879,7 +1944,7 @@ function buildAggregates(round, stats) {
|
|
|
1879
1944
|
* - medium: 3 <= n < 10 (small sample but clean)
|
|
1880
1945
|
* - high: n >= 10 AND no errored responses AND not tied
|
|
1881
1946
|
*
|
|
1882
|
-
* Tuned for the typical 5-
|
|
1947
|
+
* Tuned for the typical 5-participant ask: a clean 5/5 lands at "medium" (you
|
|
1883
1948
|
* can probably trust the lean), 1/5 with no errors lands at "low" (you
|
|
1884
1949
|
* need more data), 5/5 with a tie lands at "low" (no winner to call).
|
|
1885
1950
|
*/
|
|
@@ -1949,15 +2014,15 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
1949
2014
|
errored += decorated.responses_errored ?? 0;
|
|
1950
2015
|
return aggregates ? { ...decorated, aggregates } : decorated;
|
|
1951
2016
|
});
|
|
1952
|
-
const
|
|
1953
|
-
const
|
|
1954
|
-
?
|
|
2017
|
+
const participants = Array.isArray(ask.participants) ? ask.participants : undefined;
|
|
2018
|
+
const dedupedParticipants = participants
|
|
2019
|
+
? participants.map((t) => dedupeParticipantSnapshot(t))
|
|
1955
2020
|
: undefined;
|
|
1956
2021
|
const payload = { ...ask, rounds: enrichedRounds };
|
|
1957
|
-
if (
|
|
1958
|
-
payload.
|
|
1959
|
-
if (
|
|
1960
|
-
payload.
|
|
2022
|
+
if (dedupedParticipants)
|
|
2023
|
+
payload.participants = dedupedParticipants;
|
|
2024
|
+
if (participants)
|
|
2025
|
+
payload.participants_count = participants.length;
|
|
1961
2026
|
if (total > 0) {
|
|
1962
2027
|
payload.responses_total = total;
|
|
1963
2028
|
payload.responses_complete = complete;
|
|
@@ -2015,8 +2080,8 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
2015
2080
|
for (const r of completed.slice(0, 10)) {
|
|
2016
2081
|
const resp = r;
|
|
2017
2082
|
const comment = resp.comment ? truncate(String(resp.comment), 120) : "-";
|
|
2018
|
-
const
|
|
2019
|
-
console.log(` ${
|
|
2083
|
+
const participant = resp.participant_id ? deterministicAlias(ALIAS_PREFIX.participant, String(resp.participant_id)) : "-";
|
|
2084
|
+
console.log(` ${participant}: ${comment}`);
|
|
2020
2085
|
}
|
|
2021
2086
|
if (completed.length > 10) {
|
|
2022
2087
|
console.log(` … and ${completed.length - 10} more`);
|