@pitcher/cli-scripts 3.2.0 → 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/dist/src/commands/dsrLanding.js +1 -1
- package/dist/src/commands/roleplay.d.ts +154 -3
- package/dist/src/commands/roleplay.js +276 -23
- package/dist/src/core/dsr-engagement.d.ts +152 -0
- package/dist/src/core/dsr-engagement.js +301 -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 +48 -7
- package/dist/src/core/roleplay-stats.js +68 -11
- 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 +29 -24
- package/dist/src/mcp/renderPolicy.d.ts +1 -0
- package/dist/src/mcp/renderPolicy.js +8 -2
- 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/roleplay.js +34 -5
- package/dist/src/mcp/tools/types.d.ts +35 -0
- package/package.json +2 -1
|
@@ -79,7 +79,7 @@ export async function runDsrLandingCreate(opts, deps = {}) {
|
|
|
79
79
|
catch (e) {
|
|
80
80
|
const err = toCliError(e);
|
|
81
81
|
return fail(createCliError(err.code, `template created (id ${id}), but set-default failed: ${err.message}. ` +
|
|
82
|
-
`Retry with: p dsr-landing set-default --instance-id ${opts.instanceId} --id ${id}
|
|
82
|
+
`Retry with: p dsr-landing set-default --instance-id ${opts.instanceId} --id ${id}`, err.backendStatus));
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
return ok(created);
|
|
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import type { AxiosInstance } from 'axios';
|
|
3
3
|
import { type CommonOpts } from '../core/http.js';
|
|
4
4
|
import { type Envelope } from '../core/envelope.js';
|
|
5
|
+
import { type AssignmentProgressLike, type RepStatus, type RoleplayAssignmentLike, type RoleplaySessionLike } from '../core/roleplay-stats.js';
|
|
5
6
|
/**
|
|
6
7
|
* AI Roleplay reads (PIT-9200, Phase 1: read-only). Two sources:
|
|
7
8
|
* - clickhouse-api `/analytics/roleplay/{kpis,sessions}` (the `roleplay_statements` replica,
|
|
@@ -17,6 +18,7 @@ export interface RoleplayDeps {
|
|
|
17
18
|
export declare const ROLEPLAY_DIFFICULTIES: readonly ["easy", "medium", "hard"];
|
|
18
19
|
export declare const ROLEPLAY_SESSION_SORT: readonly ["session_at", "rep_name", "score", "duration_ms"];
|
|
19
20
|
export declare const ROLEPLAY_GROUP_BY: readonly ["rep", "scenario"];
|
|
21
|
+
export declare const REP_STATUSES: readonly RepStatus[];
|
|
20
22
|
/** rep-summary reads at most this many pages of this size (5000 sessions), newest first. */
|
|
21
23
|
export declare const REP_SUMMARY_PAGE_SIZE = 1000;
|
|
22
24
|
export declare const REP_SUMMARY_MAX_PAGES = 5;
|
|
@@ -25,6 +27,11 @@ export declare const REP_SUMMARY_MAX_GROUPS = 50;
|
|
|
25
27
|
export declare const COACH_DATA_SESSION_CAP = 1000;
|
|
26
28
|
export declare const TEAM_MAX_ASSIGNMENTS = 50;
|
|
27
29
|
export declare const TEAM_MAX_REPS_PER_ASSIGNMENT = 100;
|
|
30
|
+
/**
|
|
31
|
+
* Pass progress (PIT-9971) is read in pages of 2000 (assignment, rep) pairs. Above this many pages the
|
|
32
|
+
* tools use no progress at all (the manager app allows 100,000 pages; one tool call stays bounded).
|
|
33
|
+
*/
|
|
34
|
+
export declare const PROGRESS_MAX_PAGES = 10;
|
|
28
35
|
export interface RoleplayFilterOpts extends CommonOpts {
|
|
29
36
|
instanceId?: string;
|
|
30
37
|
startDate?: string;
|
|
@@ -33,6 +40,7 @@ export interface RoleplayFilterOpts extends CommonOpts {
|
|
|
33
40
|
scenario?: string;
|
|
34
41
|
difficulty?: string;
|
|
35
42
|
}
|
|
43
|
+
export declare const finiteOrNull: (v: unknown) => number | null;
|
|
36
44
|
/** Totals for the window: sessions, scored/unscored, mean score over SCORED sessions only, active reps. */
|
|
37
45
|
export declare function runRoleplayKpis(opts: RoleplayFilterOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
|
|
38
46
|
declare const SESSION_FIELDS: readonly ["entry_id", "session_at", "rep_name", "who", "scenario_name", "persona_name", "difficulty", "score", "evaluated", "rating", "duration_ms"];
|
|
@@ -55,15 +63,102 @@ export interface RoleplayRepSummaryOpts extends RoleplayFilterOpts {
|
|
|
55
63
|
* window holds more than that; then the figures cover the newest 5000 only.
|
|
56
64
|
*/
|
|
57
65
|
export declare function runRoleplayRepSummary(opts: RoleplayRepSummaryOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
|
|
66
|
+
export interface CoachAssignment extends RoleplayAssignmentLike {
|
|
67
|
+
id: string;
|
|
68
|
+
scenarioName?: string;
|
|
69
|
+
active?: boolean;
|
|
70
|
+
contentType?: string;
|
|
71
|
+
resolvedUserNames?: Record<string, string>;
|
|
72
|
+
}
|
|
73
|
+
/** A bundle session row: coach/data spreads the appsDB data and adds the entry `id` (transcript stripped). */
|
|
74
|
+
export type CoachSession = RoleplaySessionLike & {
|
|
75
|
+
id: string;
|
|
76
|
+
sessionType?: string;
|
|
77
|
+
evaluationStatus?: string;
|
|
78
|
+
};
|
|
79
|
+
type ProgressEntry = {
|
|
80
|
+
assignmentId?: unknown;
|
|
81
|
+
repUserId?: unknown;
|
|
82
|
+
} & Partial<Record<keyof AssignmentProgressLike, unknown>>;
|
|
83
|
+
interface ProgressPage {
|
|
84
|
+
page: number;
|
|
85
|
+
pageSize: number;
|
|
86
|
+
totalPairs: number;
|
|
87
|
+
hasMore: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** A `cch-manager-comment` row as coach/data returns it: the appsDB data plus the entry `id` (soft-deleted rows excluded). */
|
|
90
|
+
export type CoachComment = Record<string, unknown> & {
|
|
91
|
+
id: string;
|
|
92
|
+
managerId?: string | number;
|
|
93
|
+
managerName?: string;
|
|
94
|
+
repUserId?: string | number;
|
|
95
|
+
assignmentId?: string;
|
|
96
|
+
sessionEntryId?: string | null;
|
|
97
|
+
comment?: string;
|
|
98
|
+
};
|
|
99
|
+
export interface CoachBundle {
|
|
100
|
+
scope?: 'instance' | 'coach';
|
|
101
|
+
assignments?: CoachAssignment[];
|
|
102
|
+
sessions?: CoachSession[];
|
|
103
|
+
comments?: CoachComment[];
|
|
104
|
+
assignmentProgress?: ProgressEntry[];
|
|
105
|
+
assignmentProgressPage?: ProgressPage;
|
|
106
|
+
}
|
|
58
107
|
export interface RoleplayTeamOpts extends CommonOpts {
|
|
59
108
|
instanceId?: string;
|
|
60
109
|
assignmentId?: string;
|
|
61
110
|
status?: string;
|
|
62
111
|
}
|
|
112
|
+
export type CoachScope = 'instance' | 'coach';
|
|
113
|
+
/** The caller's OWN coaching scope (coach/data without `scope`): what a coach may comment on (PIT-10113). */
|
|
114
|
+
export declare function getOwnCoachBundle(client: AxiosInstance, instanceId: string): Promise<CoachBundle>;
|
|
115
|
+
/** The manager app's progress map key (`useManagerData.ts`). */
|
|
116
|
+
export declare const progressKey: (assignmentId: string, repUserId: string) => string;
|
|
117
|
+
export interface Coverage {
|
|
118
|
+
sessionsCapped: boolean;
|
|
119
|
+
progress: 'full' | 'none';
|
|
120
|
+
/** (assignment, rep) pairs of the roleplay model with no progress entry when progress is full, else 0. */
|
|
121
|
+
pairsWithoutProgress: number;
|
|
122
|
+
}
|
|
123
|
+
export interface TeamModel {
|
|
124
|
+
/** The scope the server applied (an older next-core that ignores scope=instance answers 'coach'). */
|
|
125
|
+
scope: CoachScope;
|
|
126
|
+
/** The scope this load asked for first: 'coach' when the caller passed scopePref 'coach'. */
|
|
127
|
+
requested: CoachScope;
|
|
128
|
+
coverage: Coverage;
|
|
129
|
+
warnings: string[];
|
|
130
|
+
/** contentType !== 'practice', D18 order. */
|
|
131
|
+
assignments: CoachAssignment[];
|
|
132
|
+
/** sessionType !== 'manager-video-practice'. */
|
|
133
|
+
sessions: CoachSession[];
|
|
134
|
+
progress: ReadonlyMap<string, AssignmentProgressLike> | null;
|
|
135
|
+
}
|
|
136
|
+
/** D18 rep order: the reps who need the manager first. */
|
|
137
|
+
export declare const STATUS_RANK: Record<RepStatus, number>;
|
|
138
|
+
/** D18 assignment order: active first, then due date ascending (none last), then scenario name, then id. */
|
|
139
|
+
export declare function compareAssignments(a: CoachAssignment, b: CoachAssignment): number;
|
|
140
|
+
/** D18 rep order: status rank, then name (no name last), then user id. */
|
|
141
|
+
export declare function compareReps(a: {
|
|
142
|
+
status: RepStatus;
|
|
143
|
+
name: string | null;
|
|
144
|
+
user_id: string;
|
|
145
|
+
}, b: {
|
|
146
|
+
status: RepStatus;
|
|
147
|
+
name: string | null;
|
|
148
|
+
user_id: string;
|
|
149
|
+
}): number;
|
|
150
|
+
/**
|
|
151
|
+
* The one team derivation shared by `roleplay team` and the roleplay manager view (PIT-10068): the
|
|
152
|
+
* coach bundle, its pass progress, the roleplay filters and the D18 order, so the text tool and the
|
|
153
|
+
* view always cut the same 50 assignments.
|
|
154
|
+
*/
|
|
155
|
+
export declare function loadTeamModel(client: AxiosInstance, instanceId: string, scopePref?: CoachScope): Promise<TeamModel>;
|
|
63
156
|
/**
|
|
64
157
|
* Assignment completion per rep, ported from the manager app: an admin gets every roleplay
|
|
65
158
|
* assignment on the instance (`scope=instance`), anyone else the assignments they coach.
|
|
66
|
-
* Statuses follow `deriveStatus
|
|
159
|
+
* Statuses, session counts and best scores follow `deriveStatus` / `repStats` with the server's
|
|
160
|
+
* pass progress when it loads (PIT-9971); assignments and reps in D18 order (`loadTeamModel`).
|
|
161
|
+
* Raw sessions, evaluations and comments never leave.
|
|
67
162
|
*/
|
|
68
163
|
export declare function runRoleplayTeam(opts: RoleplayTeamOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
|
|
69
164
|
export interface RoleplaySessionGetOpts extends CommonOpts {
|
|
@@ -71,8 +166,10 @@ export interface RoleplaySessionGetOpts extends CommonOpts {
|
|
|
71
166
|
entryId?: string;
|
|
72
167
|
}
|
|
73
168
|
/**
|
|
74
|
-
* The allowlist
|
|
75
|
-
* recording or audio ids, or any
|
|
169
|
+
* The allowlist, shared by `roleplay session` and the manager view's scorecard. Never the transcript,
|
|
170
|
+
* messages, contact name, graded snapshot, voice session, recording or audio ids, or any
|
|
171
|
+
* video-evaluation field. The two scales (`grading_scale`, `max_score`) are numbers only (PIT-10068):
|
|
172
|
+
* legacy evaluations grade on 5, and a skill score is meaningless without its scale.
|
|
76
173
|
*/
|
|
77
174
|
export declare function pickSessionDetail(id: string, data: Record<string, unknown>): Record<string, unknown>;
|
|
78
175
|
export declare function runRoleplaySessionGet(opts: RoleplaySessionGetOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
|
|
@@ -81,5 +178,59 @@ export declare function pickScenario(s: Record<string, unknown>): Record<string,
|
|
|
81
178
|
export declare function runRoleplayScenariosList(opts: CommonOpts & {
|
|
82
179
|
instanceId?: string;
|
|
83
180
|
}, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
|
|
181
|
+
export declare const ROLEPLAY_PATH_STATUSES: readonly ["published", "draft", "all"];
|
|
182
|
+
export interface RoleplayPathRow {
|
|
183
|
+
id: string;
|
|
184
|
+
name: string;
|
|
185
|
+
status: string;
|
|
186
|
+
version: number;
|
|
187
|
+
stages: number;
|
|
188
|
+
steps: number;
|
|
189
|
+
default_passing_score: number | null;
|
|
190
|
+
updated_at: string | null;
|
|
191
|
+
}
|
|
192
|
+
/** The trimmed path: counts, never the stage/step bodies. */
|
|
193
|
+
export declare function pickPath(p: Record<string, unknown>): RoleplayPathRow;
|
|
194
|
+
/** next-core `GET roleplay/paths` (builder roles; 404 when paths are off or the caller is not a member). */
|
|
195
|
+
export declare function fetchPaths(client: AxiosInstance, instanceId: string, status?: (typeof ROLEPLAY_PATH_STATUSES)[number]): Promise<Record<string, unknown>[]>;
|
|
196
|
+
export declare function runRoleplayPathsList(opts: CommonOpts & {
|
|
197
|
+
instanceId?: string;
|
|
198
|
+
status?: string;
|
|
199
|
+
}, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
|
|
200
|
+
/** next-core `GET roleplay/path-assignments`: the records in the caller's view (admins get the membership keys). */
|
|
201
|
+
export declare function fetchPathRecords(client: AxiosInstance, instanceId: string): Promise<Record<string, unknown>[]>;
|
|
202
|
+
export declare function postCoachComment(client: AxiosInstance, args: {
|
|
203
|
+
instanceId: string;
|
|
204
|
+
assignmentId: string;
|
|
205
|
+
repUserId: string;
|
|
206
|
+
sessionEntryId: string | null;
|
|
207
|
+
comment: string;
|
|
208
|
+
}, idempotencyKey: string): Promise<{
|
|
209
|
+
body: {
|
|
210
|
+
id?: string;
|
|
211
|
+
data?: Record<string, unknown>;
|
|
212
|
+
};
|
|
213
|
+
replayed: boolean;
|
|
214
|
+
}>;
|
|
215
|
+
export declare function deleteCoachComment(client: AxiosInstance, args: {
|
|
216
|
+
instanceId: string;
|
|
217
|
+
commentId: string;
|
|
218
|
+
}, idempotencyKey: string): Promise<{
|
|
219
|
+
replayed: boolean;
|
|
220
|
+
}>;
|
|
221
|
+
export declare function postPathAssignment(client: AxiosInstance, instanceId: string, body: {
|
|
222
|
+
pathId: string;
|
|
223
|
+
groupIds: string[];
|
|
224
|
+
recipientUserIds: string[];
|
|
225
|
+
managerIds: string[];
|
|
226
|
+
mandatory: boolean;
|
|
227
|
+
dueDate: string | null;
|
|
228
|
+
}, idempotencyKey: string): Promise<{
|
|
229
|
+
body: {
|
|
230
|
+
record?: Record<string, unknown>;
|
|
231
|
+
warnings?: unknown[];
|
|
232
|
+
};
|
|
233
|
+
replayed: boolean;
|
|
234
|
+
}>;
|
|
84
235
|
export declare function createRoleplayCommand(): Command;
|
|
85
236
|
export {};
|
|
@@ -4,12 +4,12 @@ import { ok, fail } from '../core/envelope.js';
|
|
|
4
4
|
import { createCliError, isCliError } from '../core/errors.js';
|
|
5
5
|
import { emitEnvelope } from '../core/output.js';
|
|
6
6
|
import { pageSizeFor } from '../core/analytics-shape.js';
|
|
7
|
-
import { assignmentSessions, repStats, sessionScore, skillAverages, } from '../core/roleplay-stats.js';
|
|
7
|
+
import { assignmentSessions, repStats, scaleOrNull, sessionScore, skillAverages, } from '../core/roleplay-stats.js';
|
|
8
8
|
import { withAuthFlags } from './withAuth.js';
|
|
9
9
|
export const ROLEPLAY_DIFFICULTIES = ['easy', 'medium', 'hard'];
|
|
10
10
|
export const ROLEPLAY_SESSION_SORT = ['session_at', 'rep_name', 'score', 'duration_ms'];
|
|
11
11
|
export const ROLEPLAY_GROUP_BY = ['rep', 'scenario'];
|
|
12
|
-
const REP_STATUSES = ['passed', 'in_progress', 'not_started', 'overdue', 'needs_coaching'];
|
|
12
|
+
export const REP_STATUSES = ['passed', 'in_progress', 'not_started', 'overdue', 'needs_coaching'];
|
|
13
13
|
/** rep-summary reads at most this many pages of this size (5000 sessions), newest first. */
|
|
14
14
|
export const REP_SUMMARY_PAGE_SIZE = 1000;
|
|
15
15
|
export const REP_SUMMARY_MAX_PAGES = 5;
|
|
@@ -18,6 +18,11 @@ export const REP_SUMMARY_MAX_GROUPS = 50;
|
|
|
18
18
|
export const COACH_DATA_SESSION_CAP = 1000;
|
|
19
19
|
export const TEAM_MAX_ASSIGNMENTS = 50;
|
|
20
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;
|
|
21
26
|
const COACH_BASE = '/core/api/protected/roleplay/coach';
|
|
22
27
|
const APPSDB_QUERY = '/core/api/protected/appsdb/query';
|
|
23
28
|
function requireInstance(opts, verb) {
|
|
@@ -47,7 +52,7 @@ function filterParams(opts, instanceId) {
|
|
|
47
52
|
function windowOf(opts) {
|
|
48
53
|
return { start_date: opts.startDate ?? null, end_date: opts.endDate ?? null };
|
|
49
54
|
}
|
|
50
|
-
const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
55
|
+
export const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
51
56
|
/** clickhouse-api's roleplay routes are instance/org-admin only: say so, and where a manager goes instead. */
|
|
52
57
|
function adminOnlyError(e) {
|
|
53
58
|
const err = toCliError(e);
|
|
@@ -209,8 +214,15 @@ export async function runRoleplayRepSummary(opts, deps = {}) {
|
|
|
209
214
|
return fail(adminOnlyError(e));
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
|
-
|
|
217
|
+
/** `scopePref: 'coach'` skips the instance attempt; otherwise instance first, the caller's coaching scope on 403. */
|
|
218
|
+
async function getCoachBundle(client, instanceId, scopePref) {
|
|
213
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();
|
|
214
226
|
try {
|
|
215
227
|
const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId, scope: 'instance' }, headers });
|
|
216
228
|
return { bundle: (res.data ?? {}), requested: 'instance' };
|
|
@@ -219,14 +231,156 @@ async function getCoachBundle(client, instanceId) {
|
|
|
219
231
|
// Not an instance or org admin: fall back to the caller's own coaching assignments.
|
|
220
232
|
if (toCliError(e).code !== 'AUTH')
|
|
221
233
|
throw e;
|
|
222
|
-
|
|
223
|
-
|
|
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;
|
|
224
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
|
+
};
|
|
225
377
|
}
|
|
226
378
|
/**
|
|
227
379
|
* Assignment completion per rep, ported from the manager app: an admin gets every roleplay
|
|
228
380
|
* assignment on the instance (`scope=instance`), anyone else the assignments they coach.
|
|
229
|
-
* Statuses follow `deriveStatus
|
|
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.
|
|
230
384
|
*/
|
|
231
385
|
export async function runRoleplayTeam(opts, deps = {}) {
|
|
232
386
|
try {
|
|
@@ -235,11 +389,9 @@ export async function runRoleplayTeam(opts, deps = {}) {
|
|
|
235
389
|
throw createCliError('VALIDATION', `--status must be one of: ${REP_STATUSES.join(', ')}`);
|
|
236
390
|
}
|
|
237
391
|
const client = clientFor(opts, deps);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const sessions = (bundle.sessions ?? []).filter((s) => s.sessionType !== 'manager-video-practice');
|
|
242
|
-
const roleplayAssignments = (bundle.assignments ?? []).filter((a) => a.contentType !== 'practice');
|
|
392
|
+
const model = await loadTeamModel(client, instanceId);
|
|
393
|
+
const { scope, sessions, progress, coverage, warnings } = model;
|
|
394
|
+
const roleplayAssignments = model.assignments;
|
|
243
395
|
const selected = opts.assignmentId ? roleplayAssignments.filter((a) => a.id === opts.assignmentId) : roleplayAssignments;
|
|
244
396
|
if (opts.assignmentId && selected.length === 0) {
|
|
245
397
|
throw createCliError('NOT_FOUND', `No roleplay assignment ${opts.assignmentId} in this ${scope === 'instance' ? 'instance' : 'coaching scope'}`);
|
|
@@ -248,10 +400,12 @@ export async function runRoleplayTeam(opts, deps = {}) {
|
|
|
248
400
|
const shownAssignments = selected.slice(0, TEAM_MAX_ASSIGNMENTS);
|
|
249
401
|
const assignments = shownAssignments.map((a) => {
|
|
250
402
|
const own = assignmentSessions(sessions, a);
|
|
251
|
-
const reps = (a.resolvedUserIds ?? [])
|
|
252
|
-
|
|
403
|
+
const reps = (a.resolvedUserIds ?? [])
|
|
404
|
+
.map((id) => ({
|
|
405
|
+
...repStats(id, own, a, now, progress?.get(progressKey(a.id, id)) ?? null),
|
|
253
406
|
name: a.resolvedUserNames?.[id] ?? null,
|
|
254
|
-
}))
|
|
407
|
+
}))
|
|
408
|
+
.sort(compareReps);
|
|
255
409
|
const statusCounts = Object.fromEntries(REP_STATUSES.map((st) => [st, reps.filter((r) => r.status === st).length]));
|
|
256
410
|
const filtered = opts.status ? reps.filter((r) => r.status === opts.status) : reps;
|
|
257
411
|
const shownReps = filtered.slice(0, TEAM_MAX_REPS_PER_ASSIGNMENT);
|
|
@@ -273,14 +427,10 @@ export async function runRoleplayTeam(opts, deps = {}) {
|
|
|
273
427
|
: {}),
|
|
274
428
|
};
|
|
275
429
|
});
|
|
276
|
-
const capped = sessions.length >= COACH_DATA_SESSION_CAP;
|
|
277
|
-
const warnings = [];
|
|
278
|
-
if (capped) {
|
|
279
|
-
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`);
|
|
280
|
-
}
|
|
281
430
|
return ok({
|
|
282
431
|
scope,
|
|
283
|
-
complete: !
|
|
432
|
+
complete: !coverage.sessionsCapped,
|
|
433
|
+
coverage,
|
|
284
434
|
assignments,
|
|
285
435
|
total_assignments: selected.length,
|
|
286
436
|
returned: assignments.length,
|
|
@@ -295,8 +445,10 @@ export async function runRoleplayTeam(opts, deps = {}) {
|
|
|
295
445
|
}
|
|
296
446
|
}
|
|
297
447
|
/**
|
|
298
|
-
* The allowlist
|
|
299
|
-
* recording or audio ids, or any
|
|
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.
|
|
300
452
|
*/
|
|
301
453
|
export function pickSessionDetail(id, data) {
|
|
302
454
|
const evaluation = (data.evaluation ?? null);
|
|
@@ -316,10 +468,12 @@ export function pickSessionDetail(id, data) {
|
|
|
316
468
|
summary: evaluation.summary ?? null,
|
|
317
469
|
strengths: Array.isArray(evaluation.strengths) ? evaluation.strengths : [],
|
|
318
470
|
improvements: Array.isArray(evaluation.improvements) ? evaluation.improvements : [],
|
|
471
|
+
grading_scale: scaleOrNull(evaluation.grading_scale),
|
|
319
472
|
category_scores: categories.map((c) => ({
|
|
320
473
|
skill_id: c.skillId ?? null,
|
|
321
474
|
title: c.title ?? null,
|
|
322
475
|
score: finiteOrNull(c.score),
|
|
476
|
+
max_score: scaleOrNull(c.max_score),
|
|
323
477
|
feedback: c.feedback ?? null,
|
|
324
478
|
})),
|
|
325
479
|
}
|
|
@@ -385,6 +539,97 @@ export async function runRoleplayScenariosList(opts, deps = {}) {
|
|
|
385
539
|
return fail(toCliError(e));
|
|
386
540
|
}
|
|
387
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
|
+
}
|
|
388
633
|
// ── commander ────────────────────────────────────────────────────────────────
|
|
389
634
|
const ADMIN_NOTE = 'instance or org admins only';
|
|
390
635
|
function withFilterFlags(cmd) {
|
|
@@ -445,5 +690,13 @@ export function createRoleplayCommand() {
|
|
|
445
690
|
const globals = command.optsWithGlobals();
|
|
446
691
|
emitEnvelope(await runRoleplayScenariosList({ ...options, quiet: globals.quiet }), globals);
|
|
447
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
|
+
});
|
|
448
701
|
return cmd;
|
|
449
702
|
}
|