@ishlabs/cli 0.17.6 → 0.18.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 +295 -271
- package/dist/commands/study.js +89 -66
- 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 +83 -53
- package/dist/lib/docs.js +560 -386
- 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/observability.d.ts +11 -0
- package/dist/lib/observability.js +16 -3
- package/dist/lib/output.d.ts +13 -12
- package/dist/lib/output.js +244 -184
- package/dist/lib/profile-sources.d.ts +64 -16
- package/dist/lib/profile-sources.js +91 -30
- package/dist/lib/skill-content.js +215 -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/types.d.ts +105 -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`).
|
|
@@ -809,14 +810,14 @@ export function formatStudyList(studies, json) {
|
|
|
809
810
|
function detectStudyStatusInconsistency(study) {
|
|
810
811
|
if (study.status !== "failed")
|
|
811
812
|
return null;
|
|
812
|
-
const
|
|
813
|
-
const completedCount =
|
|
814
|
-
const totalInteractions =
|
|
813
|
+
const allParticipants = collectParticipants(study);
|
|
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,6 +825,60 @@ 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
|
}
|
|
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
|
+
}
|
|
827
882
|
export function formatStudyDetail(study, json, options = {}) {
|
|
828
883
|
const inconsistency = detectStudyStatusInconsistency(study);
|
|
829
884
|
if (inconsistency)
|
|
@@ -860,6 +915,7 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
860
915
|
if (a.instructions) {
|
|
861
916
|
console.log(` ${truncate(String(a.instructions), 80)}`);
|
|
862
917
|
}
|
|
918
|
+
renderAssignmentSteps(a);
|
|
863
919
|
}
|
|
864
920
|
}
|
|
865
921
|
// Interview Questions
|
|
@@ -872,12 +928,12 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
872
928
|
console.log(` ${i + 1}. ${q.question || "Untitled"} ${typeStr}`);
|
|
873
929
|
}
|
|
874
930
|
}
|
|
875
|
-
//
|
|
876
|
-
const
|
|
877
|
-
if (
|
|
878
|
-
console.log(`\
|
|
879
|
-
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"],
|
|
880
|
-
t.id ? deterministicAlias(ALIAS_PREFIX.
|
|
931
|
+
// Participants summary
|
|
932
|
+
const allParticipants = collectParticipants(study);
|
|
933
|
+
if (allParticipants.length > 0) {
|
|
934
|
+
console.log(`\nParticipants (${allParticipants.length}):`);
|
|
935
|
+
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS"], allParticipants.map((t) => [
|
|
936
|
+
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
881
937
|
t.name,
|
|
882
938
|
t.iterationLabel,
|
|
883
939
|
t.status,
|
|
@@ -891,15 +947,15 @@ export function formatStudyDetail(study, json, options = {}) {
|
|
|
891
947
|
* Agents can rely on the keys always being present (M4).
|
|
892
948
|
*/
|
|
893
949
|
function buildStudyResultsEnvelope(study) {
|
|
894
|
-
const
|
|
950
|
+
const allParticipants = collectParticipants(study);
|
|
895
951
|
const studyAlias = study.id
|
|
896
952
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
897
953
|
: null;
|
|
898
|
-
const completedCount =
|
|
899
|
-
// Aggregate sentiment across all interactions on all
|
|
954
|
+
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
955
|
+
// Aggregate sentiment across all interactions on all participants.
|
|
900
956
|
const sentimentCounts = {};
|
|
901
957
|
let sentimentTotal = 0;
|
|
902
|
-
for (const t of
|
|
958
|
+
for (const t of allParticipants) {
|
|
903
959
|
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
904
960
|
sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
|
|
905
961
|
sentimentTotal += count;
|
|
@@ -912,18 +968,18 @@ function buildStudyResultsEnvelope(study) {
|
|
|
912
968
|
}
|
|
913
969
|
: null;
|
|
914
970
|
// Group interview answers by question for easy parsing. Each answer row
|
|
915
|
-
// includes the
|
|
916
|
-
// sentiment per answer without round-tripping `study
|
|
971
|
+
// includes the participant's session-level `sentiment` (M10) so agents can read
|
|
972
|
+
// sentiment per answer without round-tripping `study participant <id>` per row.
|
|
917
973
|
const questions = Array.isArray(study.interview_questions) ? study.interview_questions : [];
|
|
918
974
|
const interviewAnswers = questions.map((q) => {
|
|
919
975
|
const qObj = q;
|
|
920
976
|
const answers = [];
|
|
921
|
-
for (const t of
|
|
977
|
+
for (const t of allParticipants) {
|
|
922
978
|
const a = t.interviewAnswers.find((x) => x.questionId === qObj.id);
|
|
923
979
|
if (a) {
|
|
924
980
|
answers.push({
|
|
925
|
-
|
|
926
|
-
|
|
981
|
+
participant_alias: t.alias || null,
|
|
982
|
+
participant_name: t.name,
|
|
927
983
|
iteration: t.iterationLabel,
|
|
928
984
|
answer: a.answer,
|
|
929
985
|
sentiment: t.summarySentiment,
|
|
@@ -940,10 +996,10 @@ function buildStudyResultsEnvelope(study) {
|
|
|
940
996
|
// field when the backend reports failed-with-data; agents can branch on
|
|
941
997
|
// either the original status or status_inferred.
|
|
942
998
|
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
|
|
999
|
+
// Pattern B2 (cli half): per-participant rows expose status + error_message so
|
|
1000
|
+
// agents can act on a failed run without re-fetching every participant.
|
|
1001
|
+
const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
|
|
1002
|
+
const participantRows = allParticipants.map((t) => ({
|
|
947
1003
|
alias: t.alias || null,
|
|
948
1004
|
name: t.name,
|
|
949
1005
|
iteration: t.iterationLabel,
|
|
@@ -961,12 +1017,12 @@ function buildStudyResultsEnvelope(study) {
|
|
|
961
1017
|
...(inconsistency && { status_inferred: inconsistency.inferred }),
|
|
962
1018
|
modality: study.modality || null,
|
|
963
1019
|
},
|
|
964
|
-
|
|
1020
|
+
participant_count: allParticipants.length,
|
|
965
1021
|
completed_count: completedCount,
|
|
966
1022
|
failed_count: failedCount,
|
|
967
1023
|
sentiment,
|
|
968
1024
|
interview_answers: interviewAnswers,
|
|
969
|
-
|
|
1025
|
+
participants: participantRows,
|
|
970
1026
|
};
|
|
971
1027
|
}
|
|
972
1028
|
export function formatStudyResults(study, json) {
|
|
@@ -980,14 +1036,14 @@ export function formatStudyResults(study, json) {
|
|
|
980
1036
|
console.log(jsonOutput(buildStudyResultsEnvelope(study), { preProjected: true }));
|
|
981
1037
|
return;
|
|
982
1038
|
}
|
|
983
|
-
const
|
|
984
|
-
const totalInteractions =
|
|
1039
|
+
const allParticipants = collectParticipants(study);
|
|
1040
|
+
const totalInteractions = allParticipants.reduce((sum, t) => sum + t.interactionCount, 0);
|
|
985
1041
|
// Header
|
|
986
1042
|
console.log(`${study.name || "Untitled"} — Results`);
|
|
987
|
-
console.log(`${
|
|
1043
|
+
console.log(`${allParticipants.length} participant${allParticipants.length !== 1 ? "s" : ""} · ${totalInteractions} total interactions`);
|
|
988
1044
|
// Sentiment summary
|
|
989
1045
|
const totalSentiment = {};
|
|
990
|
-
for (const t of
|
|
1046
|
+
for (const t of allParticipants) {
|
|
991
1047
|
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
992
1048
|
totalSentiment[label] = (totalSentiment[label] || 0) + count;
|
|
993
1049
|
}
|
|
@@ -1002,7 +1058,7 @@ export function formatStudyResults(study, json) {
|
|
|
1002
1058
|
const qObj = q;
|
|
1003
1059
|
const typeStr = formatQuestionType(qObj);
|
|
1004
1060
|
console.log(`\n "${qObj.question}" ${typeStr}`);
|
|
1005
|
-
for (const t of
|
|
1061
|
+
for (const t of allParticipants) {
|
|
1006
1062
|
const answer = t.interviewAnswers.find((a) => a.questionId === qObj.id);
|
|
1007
1063
|
if (answer) {
|
|
1008
1064
|
const answerStr = typeof answer.answer === "string"
|
|
@@ -1013,13 +1069,13 @@ export function formatStudyResults(study, json) {
|
|
|
1013
1069
|
}
|
|
1014
1070
|
}
|
|
1015
1071
|
}
|
|
1016
|
-
//
|
|
1017
|
-
if (
|
|
1018
|
-
console.log("\
|
|
1019
|
-
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"],
|
|
1072
|
+
// Participants table
|
|
1073
|
+
if (allParticipants.length > 0) {
|
|
1074
|
+
console.log("\nParticipants:");
|
|
1075
|
+
printTable(["#", "NAME", "ITERATION", "STATUS", "INTERACTIONS", "SENTIMENT"], allParticipants.map((t) => {
|
|
1020
1076
|
const parts = Object.entries(t.sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
1021
1077
|
return [
|
|
1022
|
-
t.id ? deterministicAlias(ALIAS_PREFIX.
|
|
1078
|
+
t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id,
|
|
1023
1079
|
t.name,
|
|
1024
1080
|
t.iterationLabel,
|
|
1025
1081
|
t.status,
|
|
@@ -1028,41 +1084,41 @@ export function formatStudyResults(study, json) {
|
|
|
1028
1084
|
];
|
|
1029
1085
|
}));
|
|
1030
1086
|
// Pattern B2: list any failure reasons under the table so agents see why
|
|
1031
|
-
// a run failed without drilling into `study
|
|
1032
|
-
const failedRows =
|
|
1087
|
+
// a run failed without drilling into `study participant <id>`.
|
|
1088
|
+
const failedRows = allParticipants.filter((t) => t.status.toLowerCase() === "failed" && t.errorMessage);
|
|
1033
1089
|
if (failedRows.length > 0) {
|
|
1034
|
-
console.log("\nFailed
|
|
1090
|
+
console.log("\nFailed participants:");
|
|
1035
1091
|
for (const t of failedRows) {
|
|
1036
|
-
const alias = t.id ? deterministicAlias(ALIAS_PREFIX.
|
|
1092
|
+
const alias = t.id ? deterministicAlias(ALIAS_PREFIX.participant, t.id) : t.id;
|
|
1037
1093
|
console.log(` ${alias} (${t.name}): ${truncate(t.errorMessage, 200)}`);
|
|
1038
1094
|
}
|
|
1039
1095
|
}
|
|
1040
|
-
console.log("\nRun `ish
|
|
1096
|
+
console.log("\nRun `ish participant get <id> --json` for full interaction details.");
|
|
1041
1097
|
}
|
|
1042
1098
|
}
|
|
1043
1099
|
/**
|
|
1044
|
-
* `study results --summary` projection. Drops interview_answers + per-
|
|
1100
|
+
* `study results --summary` projection. Drops interview_answers + per-participant
|
|
1045
1101
|
* interaction breakdowns; keeps headline counters, sentiment histogram, and a
|
|
1046
|
-
* per-
|
|
1102
|
+
* per-participant {alias, status, sentiment, comment} row. Useful for agents that
|
|
1047
1103
|
* need to branch on outcome without paying for the full envelope.
|
|
1048
1104
|
*/
|
|
1049
1105
|
export function buildStudyResultsSummary(study) {
|
|
1050
|
-
const
|
|
1106
|
+
const allParticipants = collectParticipants(study);
|
|
1051
1107
|
const studyAlias = study.id
|
|
1052
1108
|
? deterministicAlias(ALIAS_PREFIX.study, String(study.id))
|
|
1053
1109
|
: null;
|
|
1054
|
-
const completedCount =
|
|
1055
|
-
const failedCount =
|
|
1110
|
+
const completedCount = allParticipants.filter((t) => t.status === "completed" || t.status === "complete").length;
|
|
1111
|
+
const failedCount = allParticipants.filter((t) => t.status.toLowerCase() === "failed").length;
|
|
1056
1112
|
const sentimentCounts = {};
|
|
1057
1113
|
let sentimentTotal = 0;
|
|
1058
|
-
for (const t of
|
|
1114
|
+
for (const t of allParticipants) {
|
|
1059
1115
|
for (const [label, count] of Object.entries(t.sentimentCounts)) {
|
|
1060
1116
|
sentimentCounts[label] = (sentimentCounts[label] || 0) + count;
|
|
1061
1117
|
sentimentTotal += count;
|
|
1062
1118
|
}
|
|
1063
1119
|
}
|
|
1064
1120
|
const sentiment = sentimentTotal > 0 ? { counts: sentimentCounts, total: sentimentTotal } : null;
|
|
1065
|
-
const
|
|
1121
|
+
const participants = allParticipants.map((t) => ({
|
|
1066
1122
|
alias: t.alias || null,
|
|
1067
1123
|
name: t.name,
|
|
1068
1124
|
status: t.status,
|
|
@@ -1076,27 +1132,27 @@ export function buildStudyResultsSummary(study) {
|
|
|
1076
1132
|
name: study.name || null,
|
|
1077
1133
|
modality: study.modality || null,
|
|
1078
1134
|
},
|
|
1079
|
-
|
|
1135
|
+
participant_count: allParticipants.length,
|
|
1080
1136
|
completed_count: completedCount,
|
|
1081
1137
|
failed_count: failedCount,
|
|
1082
1138
|
sentiment,
|
|
1083
|
-
|
|
1139
|
+
participants,
|
|
1084
1140
|
};
|
|
1085
1141
|
}
|
|
1086
1142
|
/**
|
|
1087
|
-
* `study results --transcript <
|
|
1143
|
+
* `study results --transcript <participant_id>` projection. Mirrors the schema
|
|
1088
1144
|
* MCP's `get_chat_transcript` returns (`src/ish_mcp/projections.py:
|
|
1089
1145
|
* build_chat_transcript`) so callers see the same shape regardless of
|
|
1090
|
-
* surface.
|
|
1146
|
+
* surface. Participant turns whose action carries no text (e.g. select_option)
|
|
1091
1147
|
* surface `text: null`; intent lives on `action_type` + `option_label`.
|
|
1092
1148
|
* Bot turns with a `bot_reply.failure` block surface `failure` and
|
|
1093
1149
|
* `text: null` and don't count toward `unique_bot_replies`.
|
|
1094
1150
|
*/
|
|
1095
|
-
export function buildChatTranscript(
|
|
1096
|
-
const id = String(
|
|
1097
|
-
const alias = id ? deterministicAlias(ALIAS_PREFIX.
|
|
1098
|
-
const profile =
|
|
1099
|
-
const interactions = Array.isArray(
|
|
1151
|
+
export function buildChatTranscript(participant) {
|
|
1152
|
+
const id = String(participant.id || "");
|
|
1153
|
+
const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
|
|
1154
|
+
const profile = participant.person;
|
|
1155
|
+
const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
|
|
1100
1156
|
// Sort by timestamp then created_at so agent doesn't need to re-sort.
|
|
1101
1157
|
const sorted = [...interactions].sort((a, b) => {
|
|
1102
1158
|
const aIx = a;
|
|
@@ -1115,14 +1171,14 @@ export function buildChatTranscript(tester) {
|
|
|
1115
1171
|
const uniqueBotReplies = new Set();
|
|
1116
1172
|
for (const interaction of sorted) {
|
|
1117
1173
|
const ix = interaction;
|
|
1118
|
-
//
|
|
1174
|
+
// Participant turn — derive role/action from the interaction itself.
|
|
1119
1175
|
// Backend shape (post a880eba rename):
|
|
1120
1176
|
// ix.actor in {"ai", "human", "user"} (user is the actual end-user;
|
|
1121
1177
|
// we don't surface those in the transcript).
|
|
1122
1178
|
// ix.actions: [{action_type, data: {type, turn_index, text?, wire_text?,
|
|
1123
1179
|
// option_label?, said_instead?, ...}}]
|
|
1124
1180
|
// ix.bot_reply: {text?, failure?}
|
|
1125
|
-
// The
|
|
1181
|
+
// The participant's actual message text is nested under `action.data` —
|
|
1126
1182
|
// earlier versions of this builder read off the action top-level
|
|
1127
1183
|
// (`action.text`, `action.type`), which silently produced
|
|
1128
1184
|
// `text: null` on every turn (PC-C3 finding #3).
|
|
@@ -1157,7 +1213,7 @@ export function buildChatTranscript(tester) {
|
|
|
1157
1213
|
}
|
|
1158
1214
|
const actor = String(ix.actor ?? ix.interaction_type ?? "");
|
|
1159
1215
|
if (actor === "ai" || actor === "human") {
|
|
1160
|
-
// Resolve the
|
|
1216
|
+
// Resolve the participant's literal text from action.data, preferring
|
|
1161
1217
|
// the canonical wire_text the backend exposes for every action
|
|
1162
1218
|
// shape (send_text, select_option, ignore_offered, …) so the
|
|
1163
1219
|
// transcript carries the actual content on every turn — D2.
|
|
@@ -1188,7 +1244,7 @@ export function buildChatTranscript(tester) {
|
|
|
1188
1244
|
: null));
|
|
1189
1245
|
const sentimentObj = ix.sentiment;
|
|
1190
1246
|
transcript.push({
|
|
1191
|
-
role: "
|
|
1247
|
+
role: "participant",
|
|
1192
1248
|
text,
|
|
1193
1249
|
turn_index: turnIndex,
|
|
1194
1250
|
action_type: actionType,
|
|
@@ -1197,15 +1253,15 @@ export function buildChatTranscript(tester) {
|
|
|
1197
1253
|
});
|
|
1198
1254
|
}
|
|
1199
1255
|
}
|
|
1200
|
-
const summary =
|
|
1256
|
+
const summary = participant.participant_summary;
|
|
1201
1257
|
return {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
instance_name:
|
|
1258
|
+
participant_id: id || null,
|
|
1259
|
+
participant_alias: alias,
|
|
1260
|
+
instance_name: participant.instance_name ?? null,
|
|
1205
1261
|
modality: "chat",
|
|
1206
1262
|
transcript,
|
|
1207
1263
|
unique_bot_replies: uniqueBotReplies.size,
|
|
1208
|
-
|
|
1264
|
+
participant_summary: summary
|
|
1209
1265
|
? {
|
|
1210
1266
|
comment: summary.comment ?? null,
|
|
1211
1267
|
sentiment: summary.sentiment ?? null,
|
|
@@ -1215,45 +1271,45 @@ export function buildChatTranscript(tester) {
|
|
|
1215
1271
|
};
|
|
1216
1272
|
}
|
|
1217
1273
|
/**
|
|
1218
|
-
* `study
|
|
1274
|
+
* `study participant --summary` projection. Drops the action timeline; keeps the
|
|
1219
1275
|
* headline (alias, status, sentiment, comment, error_message). Useful for
|
|
1220
|
-
* the common "did this
|
|
1276
|
+
* the common "did this participant finish, what did they say" check that's
|
|
1221
1277
|
* currently buried under the full interactions array.
|
|
1222
1278
|
*/
|
|
1223
|
-
export function
|
|
1224
|
-
const id = String(
|
|
1225
|
-
const alias = id ? deterministicAlias(ALIAS_PREFIX.
|
|
1226
|
-
const profile =
|
|
1227
|
-
const summary =
|
|
1279
|
+
export function buildParticipantSummary(participant) {
|
|
1280
|
+
const id = String(participant.id || "");
|
|
1281
|
+
const alias = id ? deterministicAlias(ALIAS_PREFIX.participant, id) : null;
|
|
1282
|
+
const profile = participant.person;
|
|
1283
|
+
const summary = participant.participant_summary;
|
|
1228
1284
|
const summarySentiment = summary?.sentiment;
|
|
1229
|
-
const interactions = Array.isArray(
|
|
1285
|
+
const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
|
|
1230
1286
|
const out = {
|
|
1231
|
-
|
|
1287
|
+
participant: {
|
|
1232
1288
|
alias,
|
|
1233
|
-
name: profile?.name ??
|
|
1234
|
-
iteration_id:
|
|
1235
|
-
status:
|
|
1289
|
+
name: profile?.name ?? participant.instance_name ?? null,
|
|
1290
|
+
iteration_id: participant.iteration_id ?? null,
|
|
1291
|
+
status: participant.status ?? null,
|
|
1236
1292
|
},
|
|
1237
1293
|
interaction_count: interactions.length,
|
|
1238
1294
|
sentiment: summarySentiment?.label ?? null,
|
|
1239
1295
|
comment: summary?.comment ?? null,
|
|
1240
1296
|
};
|
|
1241
|
-
if (
|
|
1242
|
-
out.error_message = String(
|
|
1243
|
-
if (
|
|
1244
|
-
out.error_kind = String(
|
|
1297
|
+
if (participant.error_message)
|
|
1298
|
+
out.error_message = String(participant.error_message);
|
|
1299
|
+
if (participant.error_kind)
|
|
1300
|
+
out.error_kind = String(participant.error_kind);
|
|
1245
1301
|
return out;
|
|
1246
1302
|
}
|
|
1247
|
-
function
|
|
1303
|
+
function collectParticipants(study) {
|
|
1248
1304
|
const iterations = Array.isArray(study.iterations) ? study.iterations : [];
|
|
1249
|
-
const
|
|
1305
|
+
const participants = [];
|
|
1250
1306
|
for (const iter of iterations) {
|
|
1251
1307
|
const it = iter;
|
|
1252
1308
|
const iterLabel = String(it.label || it.name || "-");
|
|
1253
|
-
const
|
|
1254
|
-
for (const
|
|
1255
|
-
const t =
|
|
1256
|
-
const profile = t.
|
|
1309
|
+
const iterParticipants = Array.isArray(it.participants) ? it.participants : [];
|
|
1310
|
+
for (const participant of iterParticipants) {
|
|
1311
|
+
const t = participant;
|
|
1312
|
+
const profile = t.person;
|
|
1257
1313
|
const interactions = Array.isArray(t.interactions) ? t.interactions : [];
|
|
1258
1314
|
const sentimentCounts = {};
|
|
1259
1315
|
for (const interaction of interactions) {
|
|
@@ -1265,13 +1321,13 @@ function collectTesters(study) {
|
|
|
1265
1321
|
}
|
|
1266
1322
|
}
|
|
1267
1323
|
const answers = Array.isArray(t.interview_answers) ? t.interview_answers : [];
|
|
1268
|
-
const summary = t.
|
|
1324
|
+
const summary = t.participant_summary;
|
|
1269
1325
|
const summarySentimentObj = summary?.sentiment;
|
|
1270
1326
|
const id = String(t.id || "");
|
|
1271
|
-
|
|
1327
|
+
participants.push({
|
|
1272
1328
|
id,
|
|
1273
1329
|
name: String(profile?.name || t.instance_name || "Unknown"),
|
|
1274
|
-
alias: id ? deterministicAlias(ALIAS_PREFIX.
|
|
1330
|
+
alias: id ? deterministicAlias(ALIAS_PREFIX.participant, id) : "",
|
|
1275
1331
|
iterationLabel: iterLabel,
|
|
1276
1332
|
status: String(t.status || "-"),
|
|
1277
1333
|
errorMessage: t.error_message ? String(t.error_message) : null,
|
|
@@ -1286,7 +1342,7 @@ function collectTesters(study) {
|
|
|
1286
1342
|
});
|
|
1287
1343
|
}
|
|
1288
1344
|
}
|
|
1289
|
-
return
|
|
1345
|
+
return participants;
|
|
1290
1346
|
}
|
|
1291
1347
|
function formatQuestionType(q) {
|
|
1292
1348
|
if (!q.type)
|
|
@@ -1314,26 +1370,26 @@ export function formatIterationList(iterations, json) {
|
|
|
1314
1370
|
return;
|
|
1315
1371
|
}
|
|
1316
1372
|
const aliasMap = getAliasMap(ALIAS_PREFIX.iteration);
|
|
1317
|
-
printTable(["#", "LABEL", "NAME", "
|
|
1318
|
-
const
|
|
1373
|
+
printTable(["#", "LABEL", "NAME", "PARTICIPANTS", "CREATED"], iterations.map((it) => {
|
|
1374
|
+
const participants = Array.isArray(it.participants) ? it.participants.length : 0;
|
|
1319
1375
|
return [
|
|
1320
1376
|
aliasMap.get(String(it.id)) || String(it.id || ""),
|
|
1321
1377
|
String(it.label || "-"),
|
|
1322
1378
|
String(it.name || ""),
|
|
1323
|
-
String(
|
|
1379
|
+
String(participants),
|
|
1324
1380
|
formatDate(it.created_at),
|
|
1325
1381
|
];
|
|
1326
1382
|
}));
|
|
1327
1383
|
}
|
|
1328
|
-
// ---
|
|
1329
|
-
export function
|
|
1384
|
+
// --- Participant formatting ---
|
|
1385
|
+
export function formatParticipantDetail(participant, json) {
|
|
1330
1386
|
if (json) {
|
|
1331
|
-
console.log(jsonOutput(
|
|
1387
|
+
console.log(jsonOutput(participant));
|
|
1332
1388
|
return;
|
|
1333
1389
|
}
|
|
1334
|
-
const profile =
|
|
1390
|
+
const profile = participant.person;
|
|
1335
1391
|
const profileName = profile?.name ? String(profile.name) : "Unknown";
|
|
1336
|
-
const interactions = Array.isArray(
|
|
1392
|
+
const interactions = Array.isArray(participant.interactions) ? participant.interactions : [];
|
|
1337
1393
|
// Count sentiments
|
|
1338
1394
|
const sentimentCounts = {};
|
|
1339
1395
|
for (const interaction of interactions) {
|
|
@@ -1345,17 +1401,17 @@ export function formatTesterDetail(tester, json) {
|
|
|
1345
1401
|
}
|
|
1346
1402
|
}
|
|
1347
1403
|
const sentimentParts = Object.entries(sentimentCounts).map(([label, count]) => `${count} ${label.toLowerCase()}`);
|
|
1348
|
-
const status = String(
|
|
1349
|
-
const errorMessage =
|
|
1404
|
+
const status = String(participant.status || "-");
|
|
1405
|
+
const errorMessage = participant.error_message ? String(participant.error_message) : null;
|
|
1350
1406
|
const display = {
|
|
1351
|
-
ID:
|
|
1407
|
+
ID: participant.id || "-",
|
|
1352
1408
|
Profile: profileName,
|
|
1353
1409
|
Status: status,
|
|
1354
1410
|
...(errorMessage && status.toLowerCase() === "failed" && {
|
|
1355
1411
|
Error: errorMessage,
|
|
1356
1412
|
}),
|
|
1357
|
-
Platform:
|
|
1358
|
-
Language:
|
|
1413
|
+
Platform: participant.platform || "-",
|
|
1414
|
+
Language: participant.language || "-",
|
|
1359
1415
|
Interactions: `${interactions.length} interactions`,
|
|
1360
1416
|
...(sentimentParts.length > 0 && {
|
|
1361
1417
|
Sentiment: sentimentParts.join(", "),
|
|
@@ -1363,8 +1419,8 @@ export function formatTesterDetail(tester, json) {
|
|
|
1363
1419
|
};
|
|
1364
1420
|
printKeyValue(display);
|
|
1365
1421
|
}
|
|
1366
|
-
// ---
|
|
1367
|
-
export function
|
|
1422
|
+
// --- Participant Profile formatting ---
|
|
1423
|
+
export function formatPersonList(profiles, json, limit) {
|
|
1368
1424
|
// The API may return { items: [...], total, limit, offset } or a flat array.
|
|
1369
1425
|
const wrapper = profiles;
|
|
1370
1426
|
const wasWrapper = !Array.isArray(profiles)
|
|
@@ -1377,7 +1433,7 @@ export function formatTesterProfileList(profiles, json, limit) {
|
|
|
1377
1433
|
: [];
|
|
1378
1434
|
// Client-side limit (server may not enforce it)
|
|
1379
1435
|
const list = limit ? fullList.slice(0, limit) : fullList;
|
|
1380
|
-
injectAliases(list, ALIAS_PREFIX.
|
|
1436
|
+
injectAliases(list, ALIAS_PREFIX.person);
|
|
1381
1437
|
if (json) {
|
|
1382
1438
|
// Pass through server-provided pagination when present; otherwise synthesize.
|
|
1383
1439
|
const existing = wasWrapper ? wrapper : undefined;
|
|
@@ -1385,7 +1441,7 @@ export function formatTesterProfileList(profiles, json, limit) {
|
|
|
1385
1441
|
return;
|
|
1386
1442
|
}
|
|
1387
1443
|
if (list.length === 0) {
|
|
1388
|
-
console.log("No
|
|
1444
|
+
console.log("No participant profiles.");
|
|
1389
1445
|
return;
|
|
1390
1446
|
}
|
|
1391
1447
|
printTable(["#", "NAME", "OCCUPATION", "COUNTRY", "GENDER", "AGE"], list.map((p) => [
|
|
@@ -1400,31 +1456,35 @@ export function formatTesterProfileList(profiles, json, limit) {
|
|
|
1400
1456
|
console.log(`\n Showing ${list.length} of ${fullList.length} profiles. Use --limit and --offset for more.`);
|
|
1401
1457
|
}
|
|
1402
1458
|
}
|
|
1403
|
-
// ---
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1459
|
+
// --- Participant attachment formatting ---
|
|
1460
|
+
//
|
|
1461
|
+
// `formatAttachment` is the canonical printer. `formatPersonSource` is
|
|
1462
|
+
// an alias for callers that reference the person-source name.
|
|
1463
|
+
export function formatAttachment(attachment, json) {
|
|
1464
|
+
if (attachment.id) {
|
|
1465
|
+
attachment.alias = deterministicAlias(ALIAS_PREFIX.personSource, String(attachment.id));
|
|
1407
1466
|
}
|
|
1408
1467
|
if (json) {
|
|
1409
|
-
console.log(jsonOutput(
|
|
1468
|
+
console.log(jsonOutput(attachment));
|
|
1410
1469
|
return;
|
|
1411
1470
|
}
|
|
1412
1471
|
const display = {
|
|
1413
|
-
Alias:
|
|
1414
|
-
File:
|
|
1415
|
-
Kind:
|
|
1416
|
-
Status:
|
|
1417
|
-
"Content-Type":
|
|
1472
|
+
Alias: attachment.alias || attachment.id || "-",
|
|
1473
|
+
File: attachment.file_name || attachment.original_filename || "-",
|
|
1474
|
+
Kind: attachment.kind || "-",
|
|
1475
|
+
Status: attachment.status || "-",
|
|
1476
|
+
"Content-Type": attachment.content_type || "-",
|
|
1418
1477
|
};
|
|
1419
|
-
if (
|
|
1420
|
-
display["Text length"] =
|
|
1478
|
+
if (attachment.extracted_text_length != null) {
|
|
1479
|
+
display["Text length"] = attachment.extracted_text_length;
|
|
1421
1480
|
}
|
|
1422
|
-
if (
|
|
1423
|
-
display.Error =
|
|
1481
|
+
if (attachment.error) {
|
|
1482
|
+
display.Error = attachment.error;
|
|
1424
1483
|
}
|
|
1425
1484
|
printKeyValue(display);
|
|
1426
1485
|
}
|
|
1427
|
-
|
|
1486
|
+
export const formatPersonSource = formatAttachment;
|
|
1487
|
+
// --- Generated profile list (returned by /people/generate) ---
|
|
1428
1488
|
export function formatGeneratedProfileList(profiles, json) {
|
|
1429
1489
|
const list = Array.isArray(profiles) ? profiles : [];
|
|
1430
1490
|
if (list.length === 0) {
|
|
@@ -1434,7 +1494,7 @@ export function formatGeneratedProfileList(profiles, json) {
|
|
|
1434
1494
|
console.log("No profiles generated.");
|
|
1435
1495
|
return;
|
|
1436
1496
|
}
|
|
1437
|
-
injectAliases(list, ALIAS_PREFIX.
|
|
1497
|
+
injectAliases(list, ALIAS_PREFIX.person);
|
|
1438
1498
|
if (json) {
|
|
1439
1499
|
console.log(jsonOutput(list));
|
|
1440
1500
|
return;
|
|
@@ -1457,26 +1517,26 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1457
1517
|
console.log("No simulations found.");
|
|
1458
1518
|
return;
|
|
1459
1519
|
}
|
|
1460
|
-
injectAliases(results, ALIAS_PREFIX.
|
|
1520
|
+
injectAliases(results, ALIAS_PREFIX.participant);
|
|
1461
1521
|
if (json) {
|
|
1462
1522
|
console.log(jsonOutput(results));
|
|
1463
1523
|
return;
|
|
1464
1524
|
}
|
|
1465
|
-
const aliasMap = getAliasMap(ALIAS_PREFIX.
|
|
1525
|
+
const aliasMap = getAliasMap(ALIAS_PREFIX.participant);
|
|
1466
1526
|
const countHeader = isMedia ? "SEGMENTS" : "INTERACTIONS";
|
|
1467
|
-
printTable(["#", "
|
|
1468
|
-
const id = String(r.id || r.
|
|
1527
|
+
printTable(["#", "PARTICIPANT", "STATUS", countHeader], results.map((r) => {
|
|
1528
|
+
const id = String(r.id || r.participant_id || "");
|
|
1469
1529
|
return [
|
|
1470
1530
|
aliasMap.get(id) || id,
|
|
1471
|
-
String(r.
|
|
1531
|
+
String(r.participant_name || "Unknown"),
|
|
1472
1532
|
String(r.status || "UNKNOWN"),
|
|
1473
1533
|
String(r.interaction_count ?? "0"),
|
|
1474
1534
|
];
|
|
1475
1535
|
}));
|
|
1476
|
-
// Pattern A (cli half): list per-
|
|
1477
|
-
// agents see why a simulation failed without re-fetching every
|
|
1536
|
+
// Pattern A (cli half): list per-participant error_message under the table so
|
|
1537
|
+
// agents see why a simulation failed without re-fetching every participant.
|
|
1478
1538
|
// Truncate to 200 chars; full text is available via --json or
|
|
1479
|
-
// `ish study
|
|
1539
|
+
// `ish study participant get <id>`.
|
|
1480
1540
|
const failedRows = results.filter((r) => {
|
|
1481
1541
|
const status = String(r.status || "").toLowerCase();
|
|
1482
1542
|
return (status === "failed" || status === "errored") && r.error_message;
|
|
@@ -1484,9 +1544,9 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1484
1544
|
if (failedRows.length > 0) {
|
|
1485
1545
|
console.log("\nFailed simulations:");
|
|
1486
1546
|
for (const r of failedRows) {
|
|
1487
|
-
const id = String(r.id || r.
|
|
1547
|
+
const id = String(r.id || r.participant_id || "");
|
|
1488
1548
|
const alias = aliasMap.get(id) || id;
|
|
1489
|
-
const name = String(r.
|
|
1549
|
+
const name = String(r.participant_name || "Unknown");
|
|
1490
1550
|
console.log(` ${alias} (${name}): ${truncate(String(r.error_message), 200)}`);
|
|
1491
1551
|
}
|
|
1492
1552
|
}
|
|
@@ -1509,11 +1569,11 @@ export function formatAskList(asks, json) {
|
|
|
1509
1569
|
return;
|
|
1510
1570
|
}
|
|
1511
1571
|
const aliasMap = getAliasMap(ALIAS_PREFIX.ask);
|
|
1512
|
-
printTable(["#", "NAME", "STATUS", "
|
|
1572
|
+
printTable(["#", "NAME", "STATUS", "PARTICIPANTS", "ROUNDS", "LAST ROUND", "ARCHIVED"], asks.map((a) => [
|
|
1513
1573
|
aliasMap.get(String(a.id)) || String(a.id || ""),
|
|
1514
1574
|
String(a.name || ""),
|
|
1515
1575
|
String(a.status || "-"),
|
|
1516
|
-
String(a.
|
|
1576
|
+
String(a.participant_count ?? "0"),
|
|
1517
1577
|
String(a.round_count ?? "0"),
|
|
1518
1578
|
formatDate(a.last_round_at),
|
|
1519
1579
|
a.is_archived ? "yes" : "no",
|
|
@@ -1550,7 +1610,7 @@ function denormalizeRoundCounts(round) {
|
|
|
1550
1610
|
* Layer denormalized counts onto an ask detail so agents reading
|
|
1551
1611
|
* `ask get`, `ask create --wait`, `ask run --wait`, etc. don't need to
|
|
1552
1612
|
* count nested arrays:
|
|
1553
|
-
* -
|
|
1613
|
+
* - participants_count: ask.participants.length
|
|
1554
1614
|
* - responses_total: sum across rounds (only when > 0)
|
|
1555
1615
|
* - responses_complete: sum across rounds
|
|
1556
1616
|
* - responses_errored: sum across rounds (only when > 0)
|
|
@@ -1558,9 +1618,9 @@ function denormalizeRoundCounts(round) {
|
|
|
1558
1618
|
*/
|
|
1559
1619
|
function denormalizeAskCounts(ask) {
|
|
1560
1620
|
const enriched = { ...ask };
|
|
1561
|
-
const
|
|
1562
|
-
if (
|
|
1563
|
-
enriched.
|
|
1621
|
+
const participants = Array.isArray(ask.participants) ? ask.participants : null;
|
|
1622
|
+
if (participants)
|
|
1623
|
+
enriched.participants_count = participants.length;
|
|
1564
1624
|
const rounds = Array.isArray(ask.rounds) ? ask.rounds : null;
|
|
1565
1625
|
if (rounds) {
|
|
1566
1626
|
let total = 0;
|
|
@@ -1597,22 +1657,22 @@ export function formatAskDetail(ask, json) {
|
|
|
1597
1657
|
meta.push("archived");
|
|
1598
1658
|
meta.push(formatDate(ask.created_at));
|
|
1599
1659
|
console.log(meta.join(" · "));
|
|
1600
|
-
const
|
|
1601
|
-
console.log(`\
|
|
1602
|
-
if (
|
|
1603
|
-
const rows =
|
|
1660
|
+
const participants = Array.isArray(ask.participants) ? ask.participants : [];
|
|
1661
|
+
console.log(`\nParticipants (${participants.length}):`);
|
|
1662
|
+
if (participants.length > 0) {
|
|
1663
|
+
const rows = participants.slice(0, 20).map((t) => {
|
|
1604
1664
|
const obj = t;
|
|
1605
|
-
const profile = obj.
|
|
1665
|
+
const profile = obj.person;
|
|
1606
1666
|
const name = String(profile?.name || obj.instance_name || "Unknown");
|
|
1607
1667
|
return [
|
|
1608
|
-
obj.id ? deterministicAlias(ALIAS_PREFIX.
|
|
1668
|
+
obj.id ? deterministicAlias(ALIAS_PREFIX.participant, String(obj.id)) : "-",
|
|
1609
1669
|
name,
|
|
1610
1670
|
String(obj.status || "-"),
|
|
1611
1671
|
];
|
|
1612
1672
|
});
|
|
1613
1673
|
printTable(["#", "NAME", "STATUS"], rows);
|
|
1614
|
-
if (
|
|
1615
|
-
console.log(` … and ${
|
|
1674
|
+
if (participants.length > 20)
|
|
1675
|
+
console.log(` … and ${participants.length - 20} more`);
|
|
1616
1676
|
}
|
|
1617
1677
|
const rounds = Array.isArray(ask.rounds) ? ask.rounds : [];
|
|
1618
1678
|
if (rounds.length > 0) {
|
|
@@ -1709,18 +1769,18 @@ function computeVariantStats(round) {
|
|
|
1709
1769
|
const ERROR_RATE_REFUSE_THRESHOLD = 0.5;
|
|
1710
1770
|
const N_HIGH_CONFIDENCE_FLOOR = 10;
|
|
1711
1771
|
const N_MEDIUM_CONFIDENCE_FLOOR = 3;
|
|
1712
|
-
// When
|
|
1772
|
+
// When person and person_snapshot share all overlapping fields
|
|
1713
1773
|
// (the common case — snapshot only diverges if the profile was edited after
|
|
1714
1774
|
// 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 =
|
|
1775
|
+
// snapshot-specific metadata. Saves ~500-1000 bytes per participant in JSON output.
|
|
1776
|
+
function dedupeParticipantSnapshot(participant) {
|
|
1777
|
+
const tp = participant.person;
|
|
1778
|
+
const tps = participant.person_snapshot;
|
|
1719
1779
|
if (!tp || !tps)
|
|
1720
|
-
return
|
|
1780
|
+
return participant;
|
|
1721
1781
|
const shared = Object.keys(tps).filter((k) => k in tp);
|
|
1722
1782
|
if (shared.length === 0)
|
|
1723
|
-
return
|
|
1783
|
+
return participant;
|
|
1724
1784
|
const isEmpty = (v) => {
|
|
1725
1785
|
if (v === null || v === undefined)
|
|
1726
1786
|
return true;
|
|
@@ -1738,15 +1798,15 @@ function dedupeTesterSnapshot(tester) {
|
|
|
1738
1798
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1739
1799
|
});
|
|
1740
1800
|
if (!allMatch)
|
|
1741
|
-
return
|
|
1801
|
+
return participant;
|
|
1742
1802
|
const snapshotOnly = {};
|
|
1743
1803
|
for (const k of Object.keys(tps)) {
|
|
1744
1804
|
if (!(k in tp))
|
|
1745
1805
|
snapshotOnly[k] = tps[k];
|
|
1746
1806
|
}
|
|
1747
1807
|
return {
|
|
1748
|
-
...
|
|
1749
|
-
|
|
1808
|
+
...participant,
|
|
1809
|
+
person_snapshot: { ...snapshotOnly, _matches_person: true },
|
|
1750
1810
|
};
|
|
1751
1811
|
}
|
|
1752
1812
|
// Shape per-variant stats into a machine-readable aggregates object so agents
|
|
@@ -1796,11 +1856,11 @@ function buildAggregates(round, stats) {
|
|
|
1796
1856
|
}
|
|
1797
1857
|
out.picks = picks;
|
|
1798
1858
|
// Pattern H: pick_buckets keyed by variant id (the value `add_ask_round
|
|
1799
|
-
// --subset-variant` accepts) →
|
|
1859
|
+
// --subset-variant` accepts) → participant_ids that picked it. Pre-seed with
|
|
1800
1860
|
// every declared variant id so unpicked variants surface as empty
|
|
1801
1861
|
// arrays. Only completed responses with a resolvable variant_pick_id
|
|
1802
1862
|
// contribute; an errored response carrying a stale variant_pick_id
|
|
1803
|
-
// would otherwise drag a
|
|
1863
|
+
// would otherwise drag a participant into a drill-in subset whose pick
|
|
1804
1864
|
// we can't trust.
|
|
1805
1865
|
const variants = Array.isArray(round.variants) ? round.variants : [];
|
|
1806
1866
|
const variantIdSet = new Set();
|
|
@@ -1818,7 +1878,7 @@ function buildAggregates(round, stats) {
|
|
|
1818
1878
|
if (resp.status !== "completed")
|
|
1819
1879
|
continue;
|
|
1820
1880
|
const vpid = resp.variant_pick_id;
|
|
1821
|
-
const tid = resp.
|
|
1881
|
+
const tid = resp.participant_id;
|
|
1822
1882
|
if (typeof vpid === "string"
|
|
1823
1883
|
&& variantIdSet.has(vpid)
|
|
1824
1884
|
&& typeof tid === "string"
|
|
@@ -1831,7 +1891,7 @@ function buildAggregates(round, stats) {
|
|
|
1831
1891
|
if (topCount > 0) {
|
|
1832
1892
|
// Refuse the winner when more than half of dispatched responses errored.
|
|
1833
1893
|
// Calling A or B with a 4/5 failure rate would mislead the agent into
|
|
1834
|
-
// treating one
|
|
1894
|
+
// treating one participant's pick as a verdict.
|
|
1835
1895
|
if (dispatchedTotal > 0
|
|
1836
1896
|
&& erroredTotal / dispatchedTotal > ERROR_RATE_REFUSE_THRESHOLD) {
|
|
1837
1897
|
out.winner = {
|
|
@@ -1879,7 +1939,7 @@ function buildAggregates(round, stats) {
|
|
|
1879
1939
|
* - medium: 3 <= n < 10 (small sample but clean)
|
|
1880
1940
|
* - high: n >= 10 AND no errored responses AND not tied
|
|
1881
1941
|
*
|
|
1882
|
-
* Tuned for the typical 5-
|
|
1942
|
+
* Tuned for the typical 5-participant ask: a clean 5/5 lands at "medium" (you
|
|
1883
1943
|
* can probably trust the lean), 1/5 with no errors lands at "low" (you
|
|
1884
1944
|
* need more data), 5/5 with a tie lands at "low" (no winner to call).
|
|
1885
1945
|
*/
|
|
@@ -1949,15 +2009,15 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
1949
2009
|
errored += decorated.responses_errored ?? 0;
|
|
1950
2010
|
return aggregates ? { ...decorated, aggregates } : decorated;
|
|
1951
2011
|
});
|
|
1952
|
-
const
|
|
1953
|
-
const
|
|
1954
|
-
?
|
|
2012
|
+
const participants = Array.isArray(ask.participants) ? ask.participants : undefined;
|
|
2013
|
+
const dedupedParticipants = participants
|
|
2014
|
+
? participants.map((t) => dedupeParticipantSnapshot(t))
|
|
1955
2015
|
: undefined;
|
|
1956
2016
|
const payload = { ...ask, rounds: enrichedRounds };
|
|
1957
|
-
if (
|
|
1958
|
-
payload.
|
|
1959
|
-
if (
|
|
1960
|
-
payload.
|
|
2017
|
+
if (dedupedParticipants)
|
|
2018
|
+
payload.participants = dedupedParticipants;
|
|
2019
|
+
if (participants)
|
|
2020
|
+
payload.participants_count = participants.length;
|
|
1961
2021
|
if (total > 0) {
|
|
1962
2022
|
payload.responses_total = total;
|
|
1963
2023
|
payload.responses_complete = complete;
|
|
@@ -2015,8 +2075,8 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
2015
2075
|
for (const r of completed.slice(0, 10)) {
|
|
2016
2076
|
const resp = r;
|
|
2017
2077
|
const comment = resp.comment ? truncate(String(resp.comment), 120) : "-";
|
|
2018
|
-
const
|
|
2019
|
-
console.log(` ${
|
|
2078
|
+
const participant = resp.participant_id ? deterministicAlias(ALIAS_PREFIX.participant, String(resp.participant_id)) : "-";
|
|
2079
|
+
console.log(` ${participant}: ${comment}`);
|
|
2020
2080
|
}
|
|
2021
2081
|
if (completed.length > 10) {
|
|
2022
2082
|
console.log(` … and ${completed.length - 10} more`);
|