@pitcher/cli-scripts 3.1.1 → 3.3.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 +39 -2
- package/dist/index.js +2 -0
- package/dist/src/commands/analytics.d.ts +77 -0
- package/dist/src/commands/analytics.js +309 -1
- package/dist/src/commands/dsrLanding.js +1 -1
- package/dist/src/commands/roleplay.d.ts +236 -0
- package/dist/src/commands/roleplay.js +702 -0
- package/dist/src/core/dsr-engagement.d.ts +152 -0
- package/dist/src/core/dsr-engagement.js +301 -0
- package/dist/src/core/dsr-visits.d.ts +50 -0
- package/dist/src/core/dsr-visits.js +191 -0
- package/dist/src/core/envelope.d.ts +1 -0
- package/dist/src/core/envelope.js +6 -1
- package/dist/src/core/errors.d.ts +3 -1
- package/dist/src/core/errors.js +2 -2
- package/dist/src/core/http.js +7 -5
- package/dist/src/core/ld.js +2 -1
- package/dist/src/core/me.d.ts +3 -0
- package/dist/src/core/me.js +10 -0
- package/dist/src/core/roleplay-stats.d.ts +120 -0
- package/dist/src/core/roleplay-stats.js +171 -0
- package/dist/src/mcp/apps/confirm/define.d.ts +66 -0
- package/dist/src/mcp/apps/confirm/define.js +208 -0
- package/dist/src/mcp/apps/confirm/hash.d.ts +4 -0
- package/dist/src/mcp/apps/confirm/hash.js +23 -0
- package/dist/src/mcp/apps/confirm/types.d.ts +96 -0
- package/dist/src/mcp/apps/confirm/types.js +15 -0
- package/dist/src/mcp/apps/contentPicker.d.ts +104 -0
- package/dist/src/mcp/apps/contentPicker.js +268 -0
- package/dist/src/mcp/apps/dsrEngagement.d.ts +194 -0
- package/dist/src/mcp/apps/dsrEngagement.js +737 -0
- package/dist/src/mcp/apps/registry.d.ts +14 -0
- package/dist/src/mcp/apps/registry.js +25 -0
- package/dist/src/mcp/apps/roleplayManager.d.ts +228 -0
- package/dist/src/mcp/apps/roleplayManager.js +606 -0
- package/dist/src/mcp/apps/roleplayReps.d.ts +79 -0
- package/dist/src/mcp/apps/roleplayReps.js +328 -0
- package/dist/src/mcp/apps/roleplayWrites.d.ts +6 -0
- package/dist/src/mcp/apps/roleplayWrites.js +509 -0
- package/dist/src/mcp/apps/text.d.ts +41 -0
- package/dist/src/mcp/apps/text.js +58 -0
- package/dist/src/mcp/apps/types.d.ts +23 -0
- package/dist/src/mcp/apps/types.js +12 -0
- package/dist/src/mcp/apps/writeConfirm.d.ts +3 -0
- package/dist/src/mcp/apps/writeConfirm.js +8 -0
- package/dist/src/mcp/groups.d.ts +42 -0
- package/dist/src/mcp/groups.js +89 -0
- package/dist/src/mcp/hosted.d.ts +36 -0
- package/dist/src/mcp/hosted.js +47 -1
- package/dist/src/mcp/index.d.ts +9 -3
- package/dist/src/mcp/index.js +9 -2
- package/dist/src/mcp/prompts/registry.js +8 -7
- package/dist/src/mcp/prompts/types.d.ts +3 -0
- package/dist/src/mcp/registry.d.ts +4 -0
- package/dist/src/mcp/registry.js +30 -23
- package/dist/src/mcp/renderPolicy.d.ts +2 -1
- package/dist/src/mcp/renderPolicy.js +10 -3
- package/dist/src/mcp/resources/analyticsCatalog.d.ts +1 -1
- package/dist/src/mcp/resources/analyticsCatalog.js +23 -5
- package/dist/src/mcp/resources/registry.js +2 -2
- package/dist/src/mcp/resources/types.d.ts +3 -0
- package/dist/src/mcp/result.d.ts +2 -1
- package/dist/src/mcp/result.js +8 -6
- package/dist/src/mcp/server.d.ts +62 -0
- package/dist/src/mcp/server.js +161 -10
- package/dist/src/mcp/tools/analytics.js +88 -3
- package/dist/src/mcp/tools/roleplay.d.ts +2 -0
- package/dist/src/mcp/tools/roleplay.js +135 -0
- package/dist/src/mcp/tools/types.d.ts +35 -0
- package/package.json +7 -1
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { clickhouseClientFor, clientFor, toCliError } from '../core/http.js';
|
|
3
|
+
import { ok, fail } from '../core/envelope.js';
|
|
4
|
+
import { createCliError, isCliError } from '../core/errors.js';
|
|
5
|
+
import { emitEnvelope } from '../core/output.js';
|
|
6
|
+
import { pageSizeFor } from '../core/analytics-shape.js';
|
|
7
|
+
import { assignmentSessions, repStats, scaleOrNull, sessionScore, skillAverages, } from '../core/roleplay-stats.js';
|
|
8
|
+
import { withAuthFlags } from './withAuth.js';
|
|
9
|
+
export const ROLEPLAY_DIFFICULTIES = ['easy', 'medium', 'hard'];
|
|
10
|
+
export const ROLEPLAY_SESSION_SORT = ['session_at', 'rep_name', 'score', 'duration_ms'];
|
|
11
|
+
export const ROLEPLAY_GROUP_BY = ['rep', 'scenario'];
|
|
12
|
+
export const REP_STATUSES = ['passed', 'in_progress', 'not_started', 'overdue', 'needs_coaching'];
|
|
13
|
+
/** rep-summary reads at most this many pages of this size (5000 sessions), newest first. */
|
|
14
|
+
export const REP_SUMMARY_PAGE_SIZE = 1000;
|
|
15
|
+
export const REP_SUMMARY_MAX_PAGES = 5;
|
|
16
|
+
export const REP_SUMMARY_MAX_GROUPS = 50;
|
|
17
|
+
/** next-core's coach/data returns at most this many sessions, newest first, and no total. */
|
|
18
|
+
export const COACH_DATA_SESSION_CAP = 1000;
|
|
19
|
+
export const TEAM_MAX_ASSIGNMENTS = 50;
|
|
20
|
+
export const TEAM_MAX_REPS_PER_ASSIGNMENT = 100;
|
|
21
|
+
/**
|
|
22
|
+
* Pass progress (PIT-9971) is read in pages of 2000 (assignment, rep) pairs. Above this many pages the
|
|
23
|
+
* tools use no progress at all (the manager app allows 100,000 pages; one tool call stays bounded).
|
|
24
|
+
*/
|
|
25
|
+
export const PROGRESS_MAX_PAGES = 10;
|
|
26
|
+
const COACH_BASE = '/core/api/protected/roleplay/coach';
|
|
27
|
+
const APPSDB_QUERY = '/core/api/protected/appsdb/query';
|
|
28
|
+
function requireInstance(opts, verb) {
|
|
29
|
+
if (!opts.instanceId)
|
|
30
|
+
throw createCliError('VALIDATION', `--instance-id is required for roleplay ${verb}`);
|
|
31
|
+
return opts.instanceId;
|
|
32
|
+
}
|
|
33
|
+
/** clickhouse-api params; `scenario` matches the persona OR scenario name server-side (`persona`). */
|
|
34
|
+
function filterParams(opts, instanceId) {
|
|
35
|
+
if (opts.difficulty && !ROLEPLAY_DIFFICULTIES.includes(opts.difficulty)) {
|
|
36
|
+
throw createCliError('VALIDATION', `--difficulty must be one of: ${ROLEPLAY_DIFFICULTIES.join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
const params = { instance_id: instanceId };
|
|
39
|
+
if (opts.startDate)
|
|
40
|
+
params.start_date = opts.startDate;
|
|
41
|
+
if (opts.endDate)
|
|
42
|
+
params.end_date = opts.endDate;
|
|
43
|
+
if (opts.rep)
|
|
44
|
+
params.rep = opts.rep;
|
|
45
|
+
if (opts.scenario)
|
|
46
|
+
params.persona = opts.scenario;
|
|
47
|
+
if (opts.difficulty)
|
|
48
|
+
params.difficulty = opts.difficulty;
|
|
49
|
+
return params;
|
|
50
|
+
}
|
|
51
|
+
/** Dates have NO default window here (unlike the content-analytics verbs): omitted = all time. */
|
|
52
|
+
function windowOf(opts) {
|
|
53
|
+
return { start_date: opts.startDate ?? null, end_date: opts.endDate ?? null };
|
|
54
|
+
}
|
|
55
|
+
export const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
56
|
+
/** clickhouse-api's roleplay routes are instance/org-admin only: say so, and where a manager goes instead. */
|
|
57
|
+
function adminOnlyError(e) {
|
|
58
|
+
const err = toCliError(e);
|
|
59
|
+
if (err.code !== 'AUTH')
|
|
60
|
+
return err;
|
|
61
|
+
return createCliError('AUTH', `${err.message} — roleplay analytics are for instance or org admins; a coaching manager can use roleplay team`);
|
|
62
|
+
}
|
|
63
|
+
// ── kpis ─────────────────────────────────────────────────────────────────────
|
|
64
|
+
/** Totals for the window: sessions, scored/unscored, mean score over SCORED sessions only, active reps. */
|
|
65
|
+
export async function runRoleplayKpis(opts, deps = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const instanceId = requireInstance(opts, 'kpis');
|
|
68
|
+
const params = filterParams(opts, instanceId);
|
|
69
|
+
const client = clickhouseClientFor(opts, deps);
|
|
70
|
+
const res = await client.get('/analytics/roleplay/kpis', { params });
|
|
71
|
+
return ok({ ...res.data, window: windowOf(opts) });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return fail(adminOnlyError(e));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ── sessions ─────────────────────────────────────────────────────────────────
|
|
78
|
+
const SESSION_FIELDS = [
|
|
79
|
+
'entry_id',
|
|
80
|
+
'session_at',
|
|
81
|
+
'rep_name',
|
|
82
|
+
'who',
|
|
83
|
+
'scenario_name',
|
|
84
|
+
'persona_name',
|
|
85
|
+
'difficulty',
|
|
86
|
+
'score',
|
|
87
|
+
'evaluated',
|
|
88
|
+
'rating',
|
|
89
|
+
'duration_ms',
|
|
90
|
+
];
|
|
91
|
+
/** The row allowlist: nothing else from the replica (provider, recording flags, rubric) leaves. */
|
|
92
|
+
export function pickSessionRow(row) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const f of SESSION_FIELDS)
|
|
95
|
+
out[f] = row[f] ?? null;
|
|
96
|
+
out.score = finiteOrNull(row.score);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
export async function runRoleplaySessions(opts, deps = {}) {
|
|
100
|
+
try {
|
|
101
|
+
const instanceId = requireInstance(opts, 'sessions');
|
|
102
|
+
if (opts.sortBy && !ROLEPLAY_SESSION_SORT.includes(opts.sortBy)) {
|
|
103
|
+
throw createCliError('VALIDATION', `--sort-by must be one of: ${ROLEPLAY_SESSION_SORT.join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
const sortOrder = opts.sortOrder?.toLowerCase();
|
|
106
|
+
if (sortOrder && sortOrder !== 'asc' && sortOrder !== 'desc') {
|
|
107
|
+
throw createCliError('VALIDATION', '--sort-order must be asc or desc');
|
|
108
|
+
}
|
|
109
|
+
const page = Number.isInteger(opts.page) && opts.page > 0 ? opts.page : 1;
|
|
110
|
+
const pageSize = pageSizeFor(opts.limit);
|
|
111
|
+
const params = { ...filterParams(opts, instanceId), page, page_size: pageSize };
|
|
112
|
+
if (opts.sortBy)
|
|
113
|
+
params.sort_by = opts.sortBy;
|
|
114
|
+
if (sortOrder)
|
|
115
|
+
params.sort_order = sortOrder;
|
|
116
|
+
const client = clickhouseClientFor(opts, deps);
|
|
117
|
+
const res = await client.get('/analytics/roleplay/sessions', { params });
|
|
118
|
+
const body = (res.data ?? {});
|
|
119
|
+
const rows = (Array.isArray(body.data) ? body.data : []).slice(0, pageSize).map(pickSessionRow);
|
|
120
|
+
const total = typeof body.total === 'number' ? body.total : rows.length;
|
|
121
|
+
const shown = (page - 1) * pageSize + rows.length;
|
|
122
|
+
const hasMore = shown < total;
|
|
123
|
+
return ok({
|
|
124
|
+
rows,
|
|
125
|
+
total_count: total,
|
|
126
|
+
returned: rows.length,
|
|
127
|
+
page,
|
|
128
|
+
has_more: hasMore,
|
|
129
|
+
...(hasMore
|
|
130
|
+
? { truncated: `Showing ${rows.length} of ${total.toLocaleString('en-US')} (page ${page}) — pass page=${page + 1} or narrow with filters` }
|
|
131
|
+
: {}),
|
|
132
|
+
window: windowOf(opts),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return fail(adminOnlyError(e));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
140
|
+
/**
|
|
141
|
+
* Per-rep (keyed on the rep email `who`) or per-scenario (keyed on `scenario_id`) aggregates over
|
|
142
|
+
* the sessions list, read in pages of 1000 (newest first) up to 5000. `complete: false` when the
|
|
143
|
+
* window holds more than that; then the figures cover the newest 5000 only.
|
|
144
|
+
*/
|
|
145
|
+
export async function runRoleplayRepSummary(opts, deps = {}) {
|
|
146
|
+
try {
|
|
147
|
+
const instanceId = requireInstance(opts, 'rep-summary');
|
|
148
|
+
const groupBy = opts.groupBy ?? 'rep';
|
|
149
|
+
if (!ROLEPLAY_GROUP_BY.includes(groupBy)) {
|
|
150
|
+
throw createCliError('VALIDATION', `--group-by must be one of: ${ROLEPLAY_GROUP_BY.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
const base = filterParams(opts, instanceId);
|
|
153
|
+
const client = clickhouseClientFor(opts, deps);
|
|
154
|
+
const fetched = [];
|
|
155
|
+
let total = 0;
|
|
156
|
+
for (let page = 1; page <= REP_SUMMARY_MAX_PAGES; page += 1) {
|
|
157
|
+
const res = await client.get('/analytics/roleplay/sessions', {
|
|
158
|
+
params: { ...base, page, page_size: REP_SUMMARY_PAGE_SIZE, sort_by: 'session_at', sort_order: 'desc' },
|
|
159
|
+
});
|
|
160
|
+
const body = (res.data ?? {});
|
|
161
|
+
const batch = Array.isArray(body.data) ? body.data : [];
|
|
162
|
+
total = typeof body.total === 'number' ? body.total : fetched.length + batch.length;
|
|
163
|
+
fetched.push(...batch);
|
|
164
|
+
if (batch.length < REP_SUMMARY_PAGE_SIZE || fetched.length >= total)
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
const groups = new Map();
|
|
168
|
+
for (const row of fetched) {
|
|
169
|
+
const key = String((groupBy === 'rep' ? row.who : row.scenario_id) ?? '');
|
|
170
|
+
const name = String((groupBy === 'rep' ? row.rep_name || row.who : row.scenario_name || row.persona_name) ?? '');
|
|
171
|
+
const g = groups.get(key) ?? { name, sessions: 0, scores: [], last: null };
|
|
172
|
+
g.sessions += 1;
|
|
173
|
+
const score = finiteOrNull(row.score);
|
|
174
|
+
if (score !== null)
|
|
175
|
+
g.scores.push(score);
|
|
176
|
+
const at = typeof row.session_at === 'string' ? row.session_at : null;
|
|
177
|
+
if (at && (!g.last || at > g.last))
|
|
178
|
+
g.last = at;
|
|
179
|
+
groups.set(key, g);
|
|
180
|
+
}
|
|
181
|
+
const all = Array.from(groups.entries())
|
|
182
|
+
.map(([key, g]) => ({
|
|
183
|
+
key,
|
|
184
|
+
name: g.name,
|
|
185
|
+
sessions: g.sessions,
|
|
186
|
+
scored_sessions: g.scores.length,
|
|
187
|
+
avg_score: g.scores.length ? round1(g.scores.reduce((a, b) => a + b, 0) / g.scores.length) : null,
|
|
188
|
+
last_session_at: g.last,
|
|
189
|
+
}))
|
|
190
|
+
.sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key));
|
|
191
|
+
const shown = all.slice(0, REP_SUMMARY_MAX_GROUPS);
|
|
192
|
+
const complete = fetched.length >= total;
|
|
193
|
+
const warnings = [];
|
|
194
|
+
if (!complete) {
|
|
195
|
+
warnings.push(`Covers the newest ${fetched.length.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} sessions; narrow the dates for exact figures`);
|
|
196
|
+
}
|
|
197
|
+
return ok({
|
|
198
|
+
group_by: groupBy,
|
|
199
|
+
key_field: groupBy === 'rep' ? 'who (rep email)' : 'scenario_id',
|
|
200
|
+
groups: shown,
|
|
201
|
+
total_groups: all.length,
|
|
202
|
+
returned: shown.length,
|
|
203
|
+
sessions_read: fetched.length,
|
|
204
|
+
total_sessions: total,
|
|
205
|
+
complete,
|
|
206
|
+
...(all.length > shown.length
|
|
207
|
+
? { truncated: `Showing the ${shown.length} ${groupBy === 'rep' ? 'reps' : 'scenarios'} with most sessions of ${all.length}` }
|
|
208
|
+
: {}),
|
|
209
|
+
...(warnings.length ? { warnings } : {}),
|
|
210
|
+
window: windowOf(opts),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
return fail(adminOnlyError(e));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** `scopePref: 'coach'` skips the instance attempt; otherwise instance first, the caller's coaching scope on 403. */
|
|
218
|
+
async function getCoachBundle(client, instanceId, scopePref) {
|
|
219
|
+
const headers = { 'x-instance-id': instanceId };
|
|
220
|
+
const asCoach = async () => {
|
|
221
|
+
const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId }, headers });
|
|
222
|
+
return { bundle: (res.data ?? {}), requested: 'coach' };
|
|
223
|
+
};
|
|
224
|
+
if (scopePref === 'coach')
|
|
225
|
+
return asCoach();
|
|
226
|
+
try {
|
|
227
|
+
const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId, scope: 'instance' }, headers });
|
|
228
|
+
return { bundle: (res.data ?? {}), requested: 'instance' };
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
// Not an instance or org admin: fall back to the caller's own coaching assignments.
|
|
232
|
+
if (toCliError(e).code !== 'AUTH')
|
|
233
|
+
throw e;
|
|
234
|
+
return asCoach();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** The caller's OWN coaching scope (coach/data without `scope`): what a coach may comment on (PIT-10113). */
|
|
238
|
+
export async function getOwnCoachBundle(client, instanceId) {
|
|
239
|
+
return (await getCoachBundle(client, instanceId, 'coach')).bundle;
|
|
240
|
+
}
|
|
241
|
+
/** The manager app's progress map key (`useManagerData.ts`). */
|
|
242
|
+
export const progressKey = (assignmentId, repUserId) => `${assignmentId}\u0000${repUserId}`;
|
|
243
|
+
function toProgress(e) {
|
|
244
|
+
return {
|
|
245
|
+
passed: e.passed === true,
|
|
246
|
+
passSource: e.passSource === 'stored' || e.passSource === 'live' ? e.passSource : null,
|
|
247
|
+
attempts: typeof e.attempts === 'number' && Number.isFinite(e.attempts) ? e.attempts : 0,
|
|
248
|
+
bestScore: finiteOrNull(e.bestScore),
|
|
249
|
+
latestAt: typeof e.latestAt === 'string' ? e.latestAt : null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const isProgressPage = (b) => Array.isArray(b.assignmentProgress) && !!b.assignmentProgressPage && typeof b.assignmentProgressPage.totalPairs === 'number';
|
|
253
|
+
const PROGRESS_CHANGED = Symbol('progress-changed');
|
|
254
|
+
/** Every page from 0, progress-only (`fetchAllProgressPages`, manager-app `lib/api.ts`). */
|
|
255
|
+
async function fetchAllProgressPages(client, instanceId, scope) {
|
|
256
|
+
const out = [];
|
|
257
|
+
let total = null;
|
|
258
|
+
for (let page = 0; page < PROGRESS_MAX_PAGES; page += 1) {
|
|
259
|
+
const params = { instance_id: instanceId, assignment_page: page };
|
|
260
|
+
if (scope === 'instance')
|
|
261
|
+
params.scope = 'instance';
|
|
262
|
+
const res = await client.get(`${COACH_BASE}/data`, { params, headers: { 'x-instance-id': instanceId } });
|
|
263
|
+
const body = (res.data ?? {});
|
|
264
|
+
if (!isProgressPage(body))
|
|
265
|
+
throw new Error(`coach/data assignment_page=${page}: no progress in the answer`);
|
|
266
|
+
if (total !== null && body.assignmentProgressPage.totalPairs !== total)
|
|
267
|
+
return PROGRESS_CHANGED;
|
|
268
|
+
total = body.assignmentProgressPage.totalPairs;
|
|
269
|
+
out.push(...body.assignmentProgress);
|
|
270
|
+
if (!body.assignmentProgressPage.hasMore)
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`coach/data: assignment progress has more than ${PROGRESS_MAX_PAGES} pages`);
|
|
274
|
+
}
|
|
275
|
+
const PROGRESS_LOST = 'Pass progress could not be loaded completely, so statuses, session counts and best scores come from the newest sessions only';
|
|
276
|
+
/**
|
|
277
|
+
* `loadAssignmentProgress`: one page = the bundle's page 0 as is; more = every page from 0, progress-only
|
|
278
|
+
* with the APPLIED scope, restarted once if `totalPairs` changes; any failure, a second change or more
|
|
279
|
+
* than PROGRESS_MAX_PAGES pages = no progress (all-or-nothing). Absent progress is normal (empty bundle,
|
|
280
|
+
* older next-core, a server-side progress failure) and is not a warning.
|
|
281
|
+
*/
|
|
282
|
+
async function loadProgress(client, instanceId, bundle, scope, warnings) {
|
|
283
|
+
if (!isProgressPage(bundle))
|
|
284
|
+
return null;
|
|
285
|
+
const first = bundle.assignmentProgressPage;
|
|
286
|
+
let entries = null;
|
|
287
|
+
if (!first.hasMore)
|
|
288
|
+
entries = bundle.assignmentProgress;
|
|
289
|
+
else if (first.pageSize > 0 && Math.ceil(first.totalPairs / first.pageSize) > PROGRESS_MAX_PAGES)
|
|
290
|
+
entries = null;
|
|
291
|
+
else {
|
|
292
|
+
try {
|
|
293
|
+
const pass = await fetchAllProgressPages(client, instanceId, scope);
|
|
294
|
+
const again = pass === PROGRESS_CHANGED ? await fetchAllProgressPages(client, instanceId, scope) : pass;
|
|
295
|
+
entries = again === PROGRESS_CHANGED ? null : again;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
entries = null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!entries) {
|
|
302
|
+
warnings.push(PROGRESS_LOST);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const map = new Map();
|
|
306
|
+
for (const e of entries) {
|
|
307
|
+
if (typeof e.assignmentId !== 'string' || (typeof e.repUserId !== 'string' && typeof e.repUserId !== 'number'))
|
|
308
|
+
continue;
|
|
309
|
+
map.set(progressKey(e.assignmentId, String(e.repUserId)), toProgress(e));
|
|
310
|
+
}
|
|
311
|
+
return map;
|
|
312
|
+
}
|
|
313
|
+
/** D18 rep order: the reps who need the manager first. */
|
|
314
|
+
export const STATUS_RANK = { overdue: 0, needs_coaching: 1, in_progress: 2, not_started: 3, passed: 4 };
|
|
315
|
+
const cmpStr = (a, b) => (a ?? '').localeCompare(b ?? '', 'en');
|
|
316
|
+
/** D18 assignment order: active first, then due date ascending (none last), then scenario name, then id. */
|
|
317
|
+
export function compareAssignments(a, b) {
|
|
318
|
+
const active = Number(b.active !== false) - Number(a.active !== false);
|
|
319
|
+
if (active)
|
|
320
|
+
return active;
|
|
321
|
+
if (a.dueDate !== b.dueDate) {
|
|
322
|
+
if (!a.dueDate)
|
|
323
|
+
return 1;
|
|
324
|
+
if (!b.dueDate)
|
|
325
|
+
return -1;
|
|
326
|
+
return a.dueDate < b.dueDate ? -1 : 1;
|
|
327
|
+
}
|
|
328
|
+
return cmpStr(a.scenarioName, b.scenarioName) || cmpStr(a.id, b.id);
|
|
329
|
+
}
|
|
330
|
+
/** D18 rep order: status rank, then name (no name last), then user id. */
|
|
331
|
+
export function compareReps(a, b) {
|
|
332
|
+
const rank = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
333
|
+
if (rank)
|
|
334
|
+
return rank;
|
|
335
|
+
if (a.name !== b.name) {
|
|
336
|
+
if (a.name === null)
|
|
337
|
+
return 1;
|
|
338
|
+
if (b.name === null)
|
|
339
|
+
return -1;
|
|
340
|
+
}
|
|
341
|
+
return cmpStr(a.name, b.name) || cmpStr(a.user_id, b.user_id);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The one team derivation shared by `roleplay team` and the roleplay manager view (PIT-10068): the
|
|
345
|
+
* coach bundle, its pass progress, the roleplay filters and the D18 order, so the text tool and the
|
|
346
|
+
* view always cut the same 50 assignments.
|
|
347
|
+
*/
|
|
348
|
+
export async function loadTeamModel(client, instanceId, scopePref) {
|
|
349
|
+
const { bundle, requested } = await getCoachBundle(client, instanceId, scopePref);
|
|
350
|
+
// An older next-core ignores scope=instance and answers without `scope`: report what was applied.
|
|
351
|
+
const scope = bundle.scope === 'instance' && requested === 'instance' ? 'instance' : 'coach';
|
|
352
|
+
const sessions = (bundle.sessions ?? []).filter((s) => s.sessionType !== 'manager-video-practice');
|
|
353
|
+
const assignments = (bundle.assignments ?? []).filter((a) => a.contentType !== 'practice').sort(compareAssignments);
|
|
354
|
+
const warnings = [];
|
|
355
|
+
const sessionsCapped = sessions.length >= COACH_DATA_SESSION_CAP;
|
|
356
|
+
if (sessionsCapped) {
|
|
357
|
+
warnings.push(`The server returned its maximum of ${COACH_DATA_SESSION_CAP} sessions (newest first); older sessions are missing, so statuses and averages may be understated`);
|
|
358
|
+
}
|
|
359
|
+
const progress = await loadProgress(client, instanceId, bundle, scope, warnings);
|
|
360
|
+
let pairsWithoutProgress = 0;
|
|
361
|
+
if (progress) {
|
|
362
|
+
for (const a of assignments) {
|
|
363
|
+
for (const id of a.resolvedUserIds ?? [])
|
|
364
|
+
if (!progress.has(progressKey(a.id, id)))
|
|
365
|
+
pairsWithoutProgress += 1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
scope,
|
|
370
|
+
requested,
|
|
371
|
+
coverage: { sessionsCapped, progress: progress ? 'full' : 'none', pairsWithoutProgress },
|
|
372
|
+
warnings,
|
|
373
|
+
assignments,
|
|
374
|
+
sessions,
|
|
375
|
+
progress,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Assignment completion per rep, ported from the manager app: an admin gets every roleplay
|
|
380
|
+
* assignment on the instance (`scope=instance`), anyone else the assignments they coach.
|
|
381
|
+
* Statuses, session counts and best scores follow `deriveStatus` / `repStats` with the server's
|
|
382
|
+
* pass progress when it loads (PIT-9971); assignments and reps in D18 order (`loadTeamModel`).
|
|
383
|
+
* Raw sessions, evaluations and comments never leave.
|
|
384
|
+
*/
|
|
385
|
+
export async function runRoleplayTeam(opts, deps = {}) {
|
|
386
|
+
try {
|
|
387
|
+
const instanceId = requireInstance(opts, 'team');
|
|
388
|
+
if (opts.status && !REP_STATUSES.includes(opts.status)) {
|
|
389
|
+
throw createCliError('VALIDATION', `--status must be one of: ${REP_STATUSES.join(', ')}`);
|
|
390
|
+
}
|
|
391
|
+
const client = clientFor(opts, deps);
|
|
392
|
+
const model = await loadTeamModel(client, instanceId);
|
|
393
|
+
const { scope, sessions, progress, coverage, warnings } = model;
|
|
394
|
+
const roleplayAssignments = model.assignments;
|
|
395
|
+
const selected = opts.assignmentId ? roleplayAssignments.filter((a) => a.id === opts.assignmentId) : roleplayAssignments;
|
|
396
|
+
if (opts.assignmentId && selected.length === 0) {
|
|
397
|
+
throw createCliError('NOT_FOUND', `No roleplay assignment ${opts.assignmentId} in this ${scope === 'instance' ? 'instance' : 'coaching scope'}`);
|
|
398
|
+
}
|
|
399
|
+
const now = new Date();
|
|
400
|
+
const shownAssignments = selected.slice(0, TEAM_MAX_ASSIGNMENTS);
|
|
401
|
+
const assignments = shownAssignments.map((a) => {
|
|
402
|
+
const own = assignmentSessions(sessions, a);
|
|
403
|
+
const reps = (a.resolvedUserIds ?? [])
|
|
404
|
+
.map((id) => ({
|
|
405
|
+
...repStats(id, own, a, now, progress?.get(progressKey(a.id, id)) ?? null),
|
|
406
|
+
name: a.resolvedUserNames?.[id] ?? null,
|
|
407
|
+
}))
|
|
408
|
+
.sort(compareReps);
|
|
409
|
+
const statusCounts = Object.fromEntries(REP_STATUSES.map((st) => [st, reps.filter((r) => r.status === st).length]));
|
|
410
|
+
const filtered = opts.status ? reps.filter((r) => r.status === opts.status) : reps;
|
|
411
|
+
const shownReps = filtered.slice(0, TEAM_MAX_REPS_PER_ASSIGNMENT);
|
|
412
|
+
return {
|
|
413
|
+
id: a.id,
|
|
414
|
+
scenario_id: a.scenarioId,
|
|
415
|
+
scenario_name: a.scenarioName ?? null,
|
|
416
|
+
type: a.type,
|
|
417
|
+
passing_score: a.passingScore,
|
|
418
|
+
due_date: a.dueDate,
|
|
419
|
+
active: a.active !== false,
|
|
420
|
+
created_at: a.createdAt,
|
|
421
|
+
rep_count: reps.length,
|
|
422
|
+
status_counts: statusCounts,
|
|
423
|
+
skills: skillAverages(own),
|
|
424
|
+
reps: shownReps,
|
|
425
|
+
...(filtered.length > shownReps.length
|
|
426
|
+
? { truncated: `Showing ${shownReps.length} of ${filtered.length} reps — filter with status` }
|
|
427
|
+
: {}),
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
return ok({
|
|
431
|
+
scope,
|
|
432
|
+
complete: !coverage.sessionsCapped,
|
|
433
|
+
coverage,
|
|
434
|
+
assignments,
|
|
435
|
+
total_assignments: selected.length,
|
|
436
|
+
returned: assignments.length,
|
|
437
|
+
...(selected.length > assignments.length
|
|
438
|
+
? { truncated: `Showing ${assignments.length} of ${selected.length} assignments — pass assignmentId` }
|
|
439
|
+
: {}),
|
|
440
|
+
...(warnings.length ? { warnings } : {}),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
return fail(toCliError(e));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The allowlist, shared by `roleplay session` and the manager view's scorecard. Never the transcript,
|
|
449
|
+
* messages, contact name, graded snapshot, voice session, recording or audio ids, or any
|
|
450
|
+
* video-evaluation field. The two scales (`grading_scale`, `max_score`) are numbers only (PIT-10068):
|
|
451
|
+
* legacy evaluations grade on 5, and a skill score is meaningless without its scale.
|
|
452
|
+
*/
|
|
453
|
+
export function pickSessionDetail(id, data) {
|
|
454
|
+
const evaluation = (data.evaluation ?? null);
|
|
455
|
+
const categories = Array.isArray(evaluation?.category_scores) ? evaluation.category_scores : [];
|
|
456
|
+
const score = sessionScore(data);
|
|
457
|
+
return {
|
|
458
|
+
id,
|
|
459
|
+
scenario_name: data.scenarioName ?? null,
|
|
460
|
+
difficulty: data.difficulty ?? null,
|
|
461
|
+
timestamp: data.timestamp ?? null,
|
|
462
|
+
duration_ms: finiteOrNull(data.durationMs),
|
|
463
|
+
overall_score: score,
|
|
464
|
+
scored: score !== null,
|
|
465
|
+
evaluation_status: data.evaluationStatus ?? null,
|
|
466
|
+
evaluation: evaluation
|
|
467
|
+
? {
|
|
468
|
+
summary: evaluation.summary ?? null,
|
|
469
|
+
strengths: Array.isArray(evaluation.strengths) ? evaluation.strengths : [],
|
|
470
|
+
improvements: Array.isArray(evaluation.improvements) ? evaluation.improvements : [],
|
|
471
|
+
grading_scale: scaleOrNull(evaluation.grading_scale),
|
|
472
|
+
category_scores: categories.map((c) => ({
|
|
473
|
+
skill_id: c.skillId ?? null,
|
|
474
|
+
title: c.title ?? null,
|
|
475
|
+
score: finiteOrNull(c.score),
|
|
476
|
+
max_score: scaleOrNull(c.max_score),
|
|
477
|
+
feedback: c.feedback ?? null,
|
|
478
|
+
})),
|
|
479
|
+
}
|
|
480
|
+
: null,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
export async function runRoleplaySessionGet(opts, deps = {}) {
|
|
484
|
+
try {
|
|
485
|
+
const instanceId = requireInstance(opts, 'session');
|
|
486
|
+
if (!opts.entryId)
|
|
487
|
+
throw createCliError('VALIDATION', '--entry-id is required for roleplay session');
|
|
488
|
+
const client = clientFor(opts, deps);
|
|
489
|
+
const res = await client.get(`${COACH_BASE}/sessions/${encodeURIComponent(opts.entryId)}`, {
|
|
490
|
+
params: { instance_id: instanceId },
|
|
491
|
+
headers: { 'x-instance-id': instanceId },
|
|
492
|
+
});
|
|
493
|
+
const body = (res.data ?? {});
|
|
494
|
+
return ok(pickSessionDetail(String(body.id ?? opts.entryId), body.data ?? {}));
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
if (isCliError(e))
|
|
498
|
+
return fail(e);
|
|
499
|
+
const err = toCliError(e);
|
|
500
|
+
if (err.code === 'AUTH') {
|
|
501
|
+
return fail(createCliError('AUTH', `${err.message} — a session opens to its rep, a coach of that rep, or an instance or org admin`));
|
|
502
|
+
}
|
|
503
|
+
return fail(err);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ── scenarios ────────────────────────────────────────────────────────────────
|
|
507
|
+
/** The trimmed catalog: never the persona prompt or the evaluation prompt. */
|
|
508
|
+
export function pickScenario(s) {
|
|
509
|
+
const persona = (s.persona ?? null);
|
|
510
|
+
const assessment = (s.assessment ?? null);
|
|
511
|
+
return {
|
|
512
|
+
id: s.id ?? null,
|
|
513
|
+
kind: s.kind === 'multi' ? 'multi' : 'single',
|
|
514
|
+
name: (persona?.name ?? s.name ?? null),
|
|
515
|
+
persona_title: persona?.title ?? null,
|
|
516
|
+
description: s.description ?? null,
|
|
517
|
+
difficulty: s.difficulty ?? 'medium',
|
|
518
|
+
active: s.active !== false,
|
|
519
|
+
time_limit_minutes: s.time_limit_minutes ?? null,
|
|
520
|
+
categories: (assessment?.categories ?? []).map((c) => ({ title: c.title ?? null, weight: c.weight ?? null })),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
export async function runRoleplayScenariosList(opts, deps = {}) {
|
|
524
|
+
try {
|
|
525
|
+
const instanceId = requireInstance(opts, 'scenarios');
|
|
526
|
+
const client = clientFor(opts, deps);
|
|
527
|
+
// Newest row only: duplicate cch-config rows exist, and the admin app reads the same one.
|
|
528
|
+
const res = await client.post(APPSDB_QUERY, {
|
|
529
|
+
type: 'cch-config',
|
|
530
|
+
where: { field: 'instance_id', operator: 'eq', value: instanceId },
|
|
531
|
+
orderBy: [{ field: 'updated_at', direction: 'desc' }],
|
|
532
|
+
limit: 1,
|
|
533
|
+
}, { headers: { 'x-instance-id': instanceId } });
|
|
534
|
+
const entry = (res.data ?? {}).entries?.[0];
|
|
535
|
+
const scenarios = Array.isArray(entry?.data?.scenarios) ? entry.data.scenarios : [];
|
|
536
|
+
return ok({ configured: !!entry, scenarios: scenarios.map(pickScenario), total_count: scenarios.length });
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
return fail(toCliError(e));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// ── learning paths (PIT-10113) ───────────────────────────────────────────────
|
|
543
|
+
const PATHS_BASE = '/core/api/protected/roleplay/paths';
|
|
544
|
+
const PATH_ASSIGNMENTS = '/core/api/protected/roleplay/path-assignments';
|
|
545
|
+
export const ROLEPLAY_PATH_STATUSES = ['published', 'draft', 'all'];
|
|
546
|
+
/** The trimmed path: counts, never the stage/step bodies. */
|
|
547
|
+
export function pickPath(p) {
|
|
548
|
+
return {
|
|
549
|
+
id: String(p.id ?? ''),
|
|
550
|
+
name: typeof p.name === 'string' ? p.name : '',
|
|
551
|
+
status: typeof p.status === 'string' ? p.status : '',
|
|
552
|
+
version: typeof p.version === 'number' ? p.version : 0,
|
|
553
|
+
stages: Array.isArray(p.stages) ? p.stages.length : 0,
|
|
554
|
+
steps: Array.isArray(p.steps) ? p.steps.length : 0,
|
|
555
|
+
default_passing_score: finiteOrNull(p.defaultPassingScore),
|
|
556
|
+
updated_at: typeof p.updatedAt === 'string' ? p.updatedAt : null,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/** next-core `GET roleplay/paths` (builder roles; 404 when paths are off or the caller is not a member). */
|
|
560
|
+
export async function fetchPaths(client, instanceId, status = 'published') {
|
|
561
|
+
const res = await client.get(PATHS_BASE, {
|
|
562
|
+
params: { instance_id: instanceId, status },
|
|
563
|
+
headers: { 'x-instance-id': instanceId },
|
|
564
|
+
});
|
|
565
|
+
const paths = res.data?.paths;
|
|
566
|
+
return Array.isArray(paths) ? paths : [];
|
|
567
|
+
}
|
|
568
|
+
export async function runRoleplayPathsList(opts, deps = {}) {
|
|
569
|
+
try {
|
|
570
|
+
const instanceId = requireInstance(opts, 'paths');
|
|
571
|
+
const status = opts.status ?? 'published';
|
|
572
|
+
if (!ROLEPLAY_PATH_STATUSES.includes(status)) {
|
|
573
|
+
throw createCliError('VALIDATION', `--status must be one of ${ROLEPLAY_PATH_STATUSES.join(', ')}`);
|
|
574
|
+
}
|
|
575
|
+
const client = clientFor(opts, deps);
|
|
576
|
+
const paths = (await fetchPaths(client, instanceId, status)).map(pickPath);
|
|
577
|
+
return ok({ paths, total_count: paths.length });
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
if (isCliError(e))
|
|
581
|
+
return fail(e);
|
|
582
|
+
const err = toCliError(e);
|
|
583
|
+
if (err.code === 'NOT_FOUND') {
|
|
584
|
+
return fail(createCliError('NOT_FOUND', 'Learning paths are not enabled on this instance, or you are not a member of it', err.backendStatus));
|
|
585
|
+
}
|
|
586
|
+
return fail(err);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** next-core `GET roleplay/path-assignments`: the records in the caller's view (admins get the membership keys). */
|
|
590
|
+
export async function fetchPathRecords(client, instanceId) {
|
|
591
|
+
const res = await client.get(PATH_ASSIGNMENTS, { params: { instance_id: instanceId }, headers: { 'x-instance-id': instanceId } });
|
|
592
|
+
const records = res.data?.records;
|
|
593
|
+
return Array.isArray(records) ? records : [];
|
|
594
|
+
}
|
|
595
|
+
// ── confirmed writes (PIT-10113): the three next-core calls, each with an Idempotency-Key ─────────────────────────
|
|
596
|
+
/** A stored answer replayed by next-core: 200 with `Idempotent-Replay: true`. */
|
|
597
|
+
function isReplay(res) {
|
|
598
|
+
const h = (res.headers ?? {});
|
|
599
|
+
const v = typeof h.get === 'function' ? h.get('idempotent-replay') : (h['idempotent-replay'] ?? h['Idempotent-Replay']);
|
|
600
|
+
return res.status === 200 && String(v).toLowerCase() === 'true';
|
|
601
|
+
}
|
|
602
|
+
export async function postCoachComment(client, args, idempotencyKey) {
|
|
603
|
+
const res = await client.post(`${COACH_BASE}/comments`, {
|
|
604
|
+
instanceId: args.instanceId,
|
|
605
|
+
sessionEntryId: args.sessionEntryId,
|
|
606
|
+
feedbackType: args.sessionEntryId ? 'session' : 'general',
|
|
607
|
+
assignmentId: args.assignmentId,
|
|
608
|
+
repUserId: args.repUserId,
|
|
609
|
+
comment: args.comment,
|
|
610
|
+
product: 'roleplay',
|
|
611
|
+
}, { headers: { 'x-instance-id': args.instanceId, 'Idempotency-Key': idempotencyKey } });
|
|
612
|
+
return { body: (res.data ?? {}), replayed: isReplay(res) };
|
|
613
|
+
}
|
|
614
|
+
export async function deleteCoachComment(client, args, idempotencyKey) {
|
|
615
|
+
const res = await client.delete(`${COACH_BASE}/comments/${encodeURIComponent(args.commentId)}`, {
|
|
616
|
+
params: { instance_id: args.instanceId },
|
|
617
|
+
headers: { 'x-instance-id': args.instanceId, 'Idempotency-Key': idempotencyKey },
|
|
618
|
+
});
|
|
619
|
+
return { replayed: isReplay(res) };
|
|
620
|
+
}
|
|
621
|
+
export async function postPathAssignment(client, instanceId, body, idempotencyKey) {
|
|
622
|
+
// exactly the six schema keys: the strict schema refuses any other (instance_id rides in the query)
|
|
623
|
+
const res = await client.post(PATH_ASSIGNMENTS, {
|
|
624
|
+
pathId: body.pathId,
|
|
625
|
+
groupIds: body.groupIds,
|
|
626
|
+
recipientUserIds: body.recipientUserIds,
|
|
627
|
+
managerIds: body.managerIds,
|
|
628
|
+
mandatory: body.mandatory,
|
|
629
|
+
dueDate: body.dueDate,
|
|
630
|
+
}, { params: { instance_id: instanceId }, headers: { 'x-instance-id': instanceId, 'Idempotency-Key': idempotencyKey } });
|
|
631
|
+
return { body: (res.data ?? {}), replayed: isReplay(res) };
|
|
632
|
+
}
|
|
633
|
+
// ── commander ────────────────────────────────────────────────────────────────
|
|
634
|
+
const ADMIN_NOTE = 'instance or org admins only';
|
|
635
|
+
function withFilterFlags(cmd) {
|
|
636
|
+
return cmd
|
|
637
|
+
.option('--instance-id <id>', 'Instance ID (required)')
|
|
638
|
+
.option('--start-date <iso>', 'Window start (ISO date; omitted = all time)')
|
|
639
|
+
.option('--end-date <iso>', 'Window end (ISO date; omitted = all time)')
|
|
640
|
+
.option('--rep <text>', 'Rep name or email contains')
|
|
641
|
+
.option('--scenario <text>', 'Scenario or persona name contains')
|
|
642
|
+
.option('--difficulty <level>', `Difficulty: ${ROLEPLAY_DIFFICULTIES.join(', ')}`);
|
|
643
|
+
}
|
|
644
|
+
export function createRoleplayCommand() {
|
|
645
|
+
const cmd = new Command('roleplay').description('read AI Roleplay sessions, scores and assignment completion');
|
|
646
|
+
withFilterFlags(withAuthFlags(cmd.command('kpis')))
|
|
647
|
+
.description(`roleplay totals: sessions, scored/unscored, mean score of scored sessions, active reps (${ADMIN_NOTE})`)
|
|
648
|
+
.action(async (options, command) => {
|
|
649
|
+
const globals = command.optsWithGlobals();
|
|
650
|
+
emitEnvelope(await runRoleplayKpis({ ...options, quiet: globals.quiet }), globals);
|
|
651
|
+
});
|
|
652
|
+
withFilterFlags(withAuthFlags(cmd.command('sessions')))
|
|
653
|
+
.option('--sort-by <field>', `Sort field: ${ROLEPLAY_SESSION_SORT.join(', ')}`)
|
|
654
|
+
.option('--sort-order <dir>', 'asc or desc (default desc)')
|
|
655
|
+
.option('--page <n>', 'Page number', (v) => parseInt(v, 10))
|
|
656
|
+
.option('--limit <n>', 'Rows per page (default 20, max 100)', (v) => parseInt(v, 10))
|
|
657
|
+
.description(`roleplay session list (${ADMIN_NOTE})`)
|
|
658
|
+
.action(async (options, command) => {
|
|
659
|
+
const globals = command.optsWithGlobals();
|
|
660
|
+
emitEnvelope(await runRoleplaySessions({ ...options, quiet: globals.quiet }), globals);
|
|
661
|
+
});
|
|
662
|
+
withFilterFlags(withAuthFlags(cmd.command('rep-summary')))
|
|
663
|
+
.option('--group-by <key>', `Group by: ${ROLEPLAY_GROUP_BY.join(', ')} (default rep)`)
|
|
664
|
+
.description(`per-rep or per-scenario session counts and mean scores (${ADMIN_NOTE})`)
|
|
665
|
+
.action(async (options, command) => {
|
|
666
|
+
const globals = command.optsWithGlobals();
|
|
667
|
+
emitEnvelope(await runRoleplayRepSummary({ ...options, quiet: globals.quiet }), globals);
|
|
668
|
+
});
|
|
669
|
+
withAuthFlags(cmd.command('team'))
|
|
670
|
+
.option('--instance-id <id>', 'Instance ID (required)')
|
|
671
|
+
.option('--assignment-id <id>', 'Only this assignment')
|
|
672
|
+
.option('--status <status>', `Only reps with this status: ${REP_STATUSES.join(', ')}`)
|
|
673
|
+
.description('assignment completion per rep: every assignment for admins, your coached ones for managers')
|
|
674
|
+
.action(async (options, command) => {
|
|
675
|
+
const globals = command.optsWithGlobals();
|
|
676
|
+
emitEnvelope(await runRoleplayTeam({ ...options, quiet: globals.quiet }), globals);
|
|
677
|
+
});
|
|
678
|
+
withAuthFlags(cmd.command('session'))
|
|
679
|
+
.option('--instance-id <id>', 'Instance ID (required)')
|
|
680
|
+
.option('--entry-id <id>', 'Session entry ID (required; entry_id from roleplay sessions)')
|
|
681
|
+
.description('one session: score, summary, strengths, improvements, per-skill scores (no transcript)')
|
|
682
|
+
.action(async (options, command) => {
|
|
683
|
+
const globals = command.optsWithGlobals();
|
|
684
|
+
emitEnvelope(await runRoleplaySessionGet({ ...options, quiet: globals.quiet }), globals);
|
|
685
|
+
});
|
|
686
|
+
withAuthFlags(cmd.command('scenarios'))
|
|
687
|
+
.option('--instance-id <id>', 'Instance ID (required)')
|
|
688
|
+
.description('the instance scenario catalog (names, difficulty, skills; no prompts)')
|
|
689
|
+
.action(async (options, command) => {
|
|
690
|
+
const globals = command.optsWithGlobals();
|
|
691
|
+
emitEnvelope(await runRoleplayScenariosList({ ...options, quiet: globals.quiet }), globals);
|
|
692
|
+
});
|
|
693
|
+
withAuthFlags(cmd.command('paths'))
|
|
694
|
+
.option('--instance-id <id>', 'Instance ID (required)')
|
|
695
|
+
.option('--status <status>', `Path status: ${ROLEPLAY_PATH_STATUSES.join(', ')} (default published)`)
|
|
696
|
+
.description('the instance learning paths: id, name, version, stage and step counts (instance admins and editors, org admins)')
|
|
697
|
+
.action(async (options, command) => {
|
|
698
|
+
const globals = command.optsWithGlobals();
|
|
699
|
+
emitEnvelope(await runRoleplayPathsList({ ...options, quiet: globals.quiet }), globals);
|
|
700
|
+
});
|
|
701
|
+
return cmd;
|
|
702
|
+
}
|