@pitcher/cli-scripts 3.1.1 → 3.2.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/roleplay.d.ts +85 -0
- package/dist/src/commands/roleplay.js +449 -0
- package/dist/src/core/dsr-visits.d.ts +50 -0
- package/dist/src/core/dsr-visits.js +191 -0
- package/dist/src/core/roleplay-stats.d.ts +79 -0
- package/dist/src/core/roleplay-stats.js +114 -0
- package/dist/src/mcp/registry.js +2 -0
- package/dist/src/mcp/renderPolicy.d.ts +1 -1
- package/dist/src/mcp/renderPolicy.js +2 -1
- package/dist/src/mcp/resources/analyticsCatalog.d.ts +1 -1
- package/dist/src/mcp/resources/analyticsCatalog.js +23 -5
- 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 +106 -0
- package/package.json +6 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One DSR viewer's event timeline (`/analytics/canvas-details/sessions`) → visits, and per visit the
|
|
3
|
+
* sections, files and pages viewed with their time (PIT-9589). The visit rule is the Sharing
|
|
4
|
+
* Analytics app's (`packages/apps/canvas-analytics/src/lib/reconstructSessions.ts`): a visit opens
|
|
5
|
+
* on an `Entered` of the canvas itself and is keyed by its `event_pair_id`; its engaged time is the
|
|
6
|
+
* sum of its focus segments (canvas Entered → Exited). Change them together.
|
|
7
|
+
*
|
|
8
|
+
* Item time: the sum of the item's `Exited` durations (`forhowlong`, seconds) in the visit. An
|
|
9
|
+
* `Entered` with no `Exited` (the unload beacon was dropped, PIT-5833) or an `Exited` reporting 0 is
|
|
10
|
+
* estimated from the gap to the next event of the visit, as the app's timeline does, and flagged
|
|
11
|
+
* `estimated`.
|
|
12
|
+
*/
|
|
13
|
+
const ITEM_TYPES = { Section: 'section', File: 'file', Page: 'page' };
|
|
14
|
+
// The page index is the LAST path segment, `<fileId>/page/<n>` or the older `<fileId>/<n>`, 0-based.
|
|
15
|
+
const PAGE_INDEX_SUFFIX = /\/(?:page\/)?(\d+)$/;
|
|
16
|
+
const ms = (ts) => new Date(ts).getTime();
|
|
17
|
+
const seconds = (from, to) => Math.max(0, Math.round((ms(to) - ms(from)) / 1000));
|
|
18
|
+
function sortPriority(e, anchorId) {
|
|
19
|
+
const isAnchor = e.entity_id === anchorId;
|
|
20
|
+
if (!isAnchor && e.action === 'Exited')
|
|
21
|
+
return 1;
|
|
22
|
+
if (isAnchor && e.action === 'Exited')
|
|
23
|
+
return 2;
|
|
24
|
+
if (isAnchor && e.action === 'Entered')
|
|
25
|
+
return 3;
|
|
26
|
+
if (!isAnchor && e.action === 'Entered')
|
|
27
|
+
return 4;
|
|
28
|
+
return 5;
|
|
29
|
+
}
|
|
30
|
+
/** `reconstructSessions`' grouping: events from an anchor `Entered` until the next one, by pair key. */
|
|
31
|
+
export function groupVisits(events, anchorId) {
|
|
32
|
+
const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp) || sortPriority(a, anchorId) - sortPriority(b, anchorId));
|
|
33
|
+
const groups = new Map();
|
|
34
|
+
let key = null;
|
|
35
|
+
let legacy = 0;
|
|
36
|
+
for (const e of sorted) {
|
|
37
|
+
if (e.entity_id === anchorId && e.action === 'Entered') {
|
|
38
|
+
key = e.event_pair_id ? `pair:${e.event_pair_id}` : `legacy:${++legacy}`;
|
|
39
|
+
}
|
|
40
|
+
if (!key)
|
|
41
|
+
continue;
|
|
42
|
+
const g = groups.get(key);
|
|
43
|
+
if (g)
|
|
44
|
+
g.push(e);
|
|
45
|
+
else
|
|
46
|
+
groups.set(key, [e]);
|
|
47
|
+
}
|
|
48
|
+
return Array.from(groups.values()).filter((g) => g.length > 0);
|
|
49
|
+
}
|
|
50
|
+
/** `computeEngagementDuration`: the wall-clock of each canvas focus segment, summed. */
|
|
51
|
+
export function engagedSeconds(events, anchorId) {
|
|
52
|
+
let total = 0;
|
|
53
|
+
let start = null;
|
|
54
|
+
let last = null;
|
|
55
|
+
const flush = (end) => {
|
|
56
|
+
if (start && end)
|
|
57
|
+
total += seconds(start, end);
|
|
58
|
+
start = null;
|
|
59
|
+
last = null;
|
|
60
|
+
};
|
|
61
|
+
for (const e of events) {
|
|
62
|
+
if (e.entity_id === anchorId) {
|
|
63
|
+
if (e.action === 'Entered') {
|
|
64
|
+
flush(last);
|
|
65
|
+
start = e.timestamp;
|
|
66
|
+
last = e.timestamp;
|
|
67
|
+
}
|
|
68
|
+
else if (e.action === 'Exited') {
|
|
69
|
+
flush(e.timestamp);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (start) {
|
|
73
|
+
last = e.timestamp;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
flush(last);
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
function itemsOf(events, anchorId) {
|
|
80
|
+
const items = new Map();
|
|
81
|
+
const open = new Map(); // entity → indexes of unmatched Entered
|
|
82
|
+
const item = (e) => {
|
|
83
|
+
const type = ITEM_TYPES[e.original_event_name];
|
|
84
|
+
if (!type || e.entity_id === anchorId)
|
|
85
|
+
return null;
|
|
86
|
+
let it = items.get(e.entity_id);
|
|
87
|
+
if (!it) {
|
|
88
|
+
const pageIdx = type === 'page' ? PAGE_INDEX_SUFFIX.exec(e.entity_id) : null;
|
|
89
|
+
it = {
|
|
90
|
+
type,
|
|
91
|
+
id: e.entity_id,
|
|
92
|
+
name: e.entity_name || e.entity_type || e.entity_id,
|
|
93
|
+
...(pageIdx ? { page_number: Number(pageIdx[1]) + 1 } : {}),
|
|
94
|
+
views: 0,
|
|
95
|
+
seconds: 0,
|
|
96
|
+
estimated: false,
|
|
97
|
+
};
|
|
98
|
+
items.set(e.entity_id, it);
|
|
99
|
+
}
|
|
100
|
+
return it;
|
|
101
|
+
};
|
|
102
|
+
const gapToNext = (i) => (i + 1 < events.length ? seconds(events[i].timestamp, events[i + 1].timestamp) : 0);
|
|
103
|
+
events.forEach((e, i) => {
|
|
104
|
+
const it = item(e);
|
|
105
|
+
if (!it)
|
|
106
|
+
return;
|
|
107
|
+
if (e.action === 'Entered') {
|
|
108
|
+
it.views += 1;
|
|
109
|
+
open.set(e.entity_id, [...(open.get(e.entity_id) ?? []), i]);
|
|
110
|
+
}
|
|
111
|
+
else if (e.action === 'Exited') {
|
|
112
|
+
const pending = open.get(e.entity_id) ?? [];
|
|
113
|
+
const enteredAt = pending.shift();
|
|
114
|
+
open.set(e.entity_id, pending);
|
|
115
|
+
if (enteredAt === undefined)
|
|
116
|
+
it.views = Math.max(it.views, 1); // an Exited-only view (adblock): still a view
|
|
117
|
+
const reported = typeof e.duration === 'number' && e.duration > 0 ? e.duration : 0;
|
|
118
|
+
if (reported > 0) {
|
|
119
|
+
it.seconds += reported;
|
|
120
|
+
}
|
|
121
|
+
else if (enteredAt !== undefined) {
|
|
122
|
+
it.seconds += seconds(events[enteredAt].timestamp, e.timestamp);
|
|
123
|
+
it.estimated = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// Entered with no Exited in the visit: the gap to the next event.
|
|
128
|
+
for (const [id, pending] of open) {
|
|
129
|
+
const it = items.get(id);
|
|
130
|
+
for (const i of pending) {
|
|
131
|
+
it.seconds += gapToNext(i);
|
|
132
|
+
it.estimated = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return Array.from(items.values());
|
|
136
|
+
}
|
|
137
|
+
export function summarizeVisits(events, canvasId) {
|
|
138
|
+
return groupVisits(events, canvasId).map((g, i) => ({
|
|
139
|
+
visit: i + 1,
|
|
140
|
+
started_at: g[0].timestamp,
|
|
141
|
+
ended_at: g[g.length - 1].timestamp,
|
|
142
|
+
engaged_seconds: engagedSeconds(g, canvasId),
|
|
143
|
+
items: itemsOf(g, canvasId),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
/** RFC 4180 cell; a leading = + - @ is prefixed with ' so a spreadsheet never runs it as a formula. */
|
|
147
|
+
export function csvCell(v) {
|
|
148
|
+
let s = v == null ? '' : String(v);
|
|
149
|
+
if (/^[=+\-@\t\r]/.test(s))
|
|
150
|
+
s = `'${s}`;
|
|
151
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
152
|
+
}
|
|
153
|
+
export const VISITS_CSV_HEADER = [
|
|
154
|
+
'viewer',
|
|
155
|
+
'visit',
|
|
156
|
+
'visit_started_at',
|
|
157
|
+
'visit_engaged_seconds',
|
|
158
|
+
'item_type',
|
|
159
|
+
'item_name',
|
|
160
|
+
'page_number',
|
|
161
|
+
'item_id',
|
|
162
|
+
'views',
|
|
163
|
+
'seconds',
|
|
164
|
+
'estimated',
|
|
165
|
+
];
|
|
166
|
+
export function visitsToCsv(viewers) {
|
|
167
|
+
const lines = [VISITS_CSV_HEADER.join(',')];
|
|
168
|
+
for (const { viewer, visits } of viewers) {
|
|
169
|
+
for (const v of visits) {
|
|
170
|
+
const items = v.items.length ? v.items : [null];
|
|
171
|
+
for (const it of items) {
|
|
172
|
+
lines.push([
|
|
173
|
+
viewer,
|
|
174
|
+
v.visit,
|
|
175
|
+
v.started_at,
|
|
176
|
+
v.engaged_seconds,
|
|
177
|
+
it?.type ?? '',
|
|
178
|
+
it?.name ?? '',
|
|
179
|
+
it?.page_number ?? '',
|
|
180
|
+
it?.id ?? '',
|
|
181
|
+
it?.views ?? '',
|
|
182
|
+
it?.seconds ?? '',
|
|
183
|
+
it ? String(it.estimated) : '',
|
|
184
|
+
]
|
|
185
|
+
.map(csvCell)
|
|
186
|
+
.join(','));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return lines.join('\n') + '\n';
|
|
191
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI Roleplay manager-app's per-rep rules, ported 1:1 so the MCP team tool and the manager
|
|
3
|
+
* app give the same answer on the same data (PIT-9200). Sources, in ng-production-apps
|
|
4
|
+
* `pitcher-coach/manager-app/src/`: `lib/session-score.ts` (reading a score), `lib/stats.ts`
|
|
5
|
+
* (best, mean, trend), `lib/status.ts` (`deriveStatus`) and `hooks/useManagerData.ts`
|
|
6
|
+
* (`getAssignmentSessions`, `getRepStats`). Change them together.
|
|
7
|
+
*
|
|
8
|
+
* The one rule every aggregate here obeys: a session is unscored iff `overallScore` is not a
|
|
9
|
+
* finite number. An unscored session is never read as 0.
|
|
10
|
+
*/
|
|
11
|
+
export interface RoleplaySessionLike {
|
|
12
|
+
userId?: string;
|
|
13
|
+
scenarioId?: string;
|
|
14
|
+
timestamp?: string;
|
|
15
|
+
overallScore?: number | null;
|
|
16
|
+
durationMs?: number | null;
|
|
17
|
+
evaluation?: {
|
|
18
|
+
category_scores?: Array<{
|
|
19
|
+
id?: string;
|
|
20
|
+
skillId?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
score?: number;
|
|
23
|
+
}>;
|
|
24
|
+
} | null;
|
|
25
|
+
}
|
|
26
|
+
export interface RoleplayAssignmentLike {
|
|
27
|
+
scenarioId: string;
|
|
28
|
+
type: 'mandatory' | 'optional' | string;
|
|
29
|
+
passingScore: number;
|
|
30
|
+
dueDate: string | null;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
resolvedUserIds: string[];
|
|
33
|
+
}
|
|
34
|
+
export type RepStatus = 'passed' | 'in_progress' | 'not_started' | 'overdue' | 'needs_coaching';
|
|
35
|
+
/** The session's score, or null when it has none (`toFiniteOrNull`). */
|
|
36
|
+
export declare function sessionScore(session: RoleplaySessionLike): number | null;
|
|
37
|
+
/** Mean of the finite values, rounded, or null when none are finite (`meanOfFinite`). */
|
|
38
|
+
export declare function meanOfFinite(values: readonly (number | null | undefined)[]): number | null;
|
|
39
|
+
/** Largest finite value, or null (`maxOfFinite`). */
|
|
40
|
+
export declare function maxOfFinite(values: readonly (number | null | undefined)[]): number | null;
|
|
41
|
+
/** Earliest-to-latest window delta over the scored entries only (`scoreTrend`). */
|
|
42
|
+
export declare function scoreTrend(scores: readonly (number | null | undefined)[], windowFor: (scoredCount: number) => number): number | null;
|
|
43
|
+
export declare const avgScore: (sessions: RoleplaySessionLike[]) => number | null;
|
|
44
|
+
export declare const bestScore: (sessions: RoleplaySessionLike[]) => number | null;
|
|
45
|
+
export declare const scoredCount: (sessions: RoleplaySessionLike[]) => number;
|
|
46
|
+
/** Up to 3 sessions per end, capped at half the scored series (`calcTrend`). */
|
|
47
|
+
export declare function calcTrend(sessions: RoleplaySessionLike[]): number | null;
|
|
48
|
+
/**
|
|
49
|
+
* `deriveStatus`: the raw best score is compared with `passingScore` as stored (legacy
|
|
50
|
+
* evaluations may grade on a 5-point scale; the manager app does not normalise either).
|
|
51
|
+
*/
|
|
52
|
+
export declare function deriveStatus(sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date): RepStatus;
|
|
53
|
+
/** `getAssignmentSessions`: same scenario, a rep of the assignment, on or after it was created. */
|
|
54
|
+
export declare function assignmentSessions<T extends RoleplaySessionLike>(sessions: T[], assignment: RoleplayAssignmentLike): T[];
|
|
55
|
+
export interface RepStats {
|
|
56
|
+
user_id: string;
|
|
57
|
+
sessions: number;
|
|
58
|
+
scored_sessions: number;
|
|
59
|
+
best_score: number | null;
|
|
60
|
+
avg_score: number | null;
|
|
61
|
+
trend: number | null;
|
|
62
|
+
status: RepStatus;
|
|
63
|
+
last_session_at: string | null;
|
|
64
|
+
total_duration_ms: number;
|
|
65
|
+
}
|
|
66
|
+
/** `getRepStats` for one rep of one assignment, without the raw sessions. */
|
|
67
|
+
export declare function repStats(userId: string, sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date): RepStats;
|
|
68
|
+
export interface SkillAverage {
|
|
69
|
+
skill_id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
avg_score: number | null;
|
|
72
|
+
scored_sessions: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Per-skill means over the sessions' `evaluation.category_scores`, keyed by `skillId` (the
|
|
76
|
+
* Skills Registry id). A legacy category without one falls back to its own `id`, which is stable
|
|
77
|
+
* within one scenario; an entry with neither, or with no finite score, is skipped.
|
|
78
|
+
*/
|
|
79
|
+
export declare function skillAverages(sessions: RoleplaySessionLike[]): SkillAverage[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI Roleplay manager-app's per-rep rules, ported 1:1 so the MCP team tool and the manager
|
|
3
|
+
* app give the same answer on the same data (PIT-9200). Sources, in ng-production-apps
|
|
4
|
+
* `pitcher-coach/manager-app/src/`: `lib/session-score.ts` (reading a score), `lib/stats.ts`
|
|
5
|
+
* (best, mean, trend), `lib/status.ts` (`deriveStatus`) and `hooks/useManagerData.ts`
|
|
6
|
+
* (`getAssignmentSessions`, `getRepStats`). Change them together.
|
|
7
|
+
*
|
|
8
|
+
* The one rule every aggregate here obeys: a session is unscored iff `overallScore` is not a
|
|
9
|
+
* finite number. An unscored session is never read as 0.
|
|
10
|
+
*/
|
|
11
|
+
/** The session's score, or null when it has none (`toFiniteOrNull`). */
|
|
12
|
+
export function sessionScore(session) {
|
|
13
|
+
const v = session.overallScore;
|
|
14
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
15
|
+
}
|
|
16
|
+
/** Mean of the finite values, rounded, or null when none are finite (`meanOfFinite`). */
|
|
17
|
+
export function meanOfFinite(values) {
|
|
18
|
+
const finite = values.filter((n) => typeof n === 'number' && Number.isFinite(n));
|
|
19
|
+
if (finite.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
return Math.round(finite.reduce((a, b) => a + b, 0) / finite.length);
|
|
22
|
+
}
|
|
23
|
+
/** Largest finite value, or null (`maxOfFinite`). */
|
|
24
|
+
export function maxOfFinite(values) {
|
|
25
|
+
const finite = values.filter((n) => typeof n === 'number' && Number.isFinite(n));
|
|
26
|
+
return finite.length === 0 ? null : Math.max(...finite);
|
|
27
|
+
}
|
|
28
|
+
/** Earliest-to-latest window delta over the scored entries only (`scoreTrend`). */
|
|
29
|
+
export function scoreTrend(scores, windowFor) {
|
|
30
|
+
const finite = scores.filter((n) => typeof n === 'number' && Number.isFinite(n));
|
|
31
|
+
if (finite.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
const n = Math.min(windowFor(finite.length), finite.length);
|
|
34
|
+
if (n < 1)
|
|
35
|
+
return null;
|
|
36
|
+
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
37
|
+
return Math.round(mean(finite.slice(-n)) - mean(finite.slice(0, n)));
|
|
38
|
+
}
|
|
39
|
+
const byTimestamp = (a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime();
|
|
40
|
+
export const avgScore = (sessions) => meanOfFinite(sessions.map(sessionScore));
|
|
41
|
+
export const bestScore = (sessions) => maxOfFinite(sessions.map(sessionScore));
|
|
42
|
+
export const scoredCount = (sessions) => sessions.filter((s) => sessionScore(s) !== null).length;
|
|
43
|
+
/** Up to 3 sessions per end, capped at half the scored series (`calcTrend`). */
|
|
44
|
+
export function calcTrend(sessions) {
|
|
45
|
+
const sorted = [...sessions].sort(byTimestamp);
|
|
46
|
+
return scoreTrend(sorted.map(sessionScore), (count) => Math.min(3, Math.floor(count / 2)));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `deriveStatus`: the raw best score is compared with `passingScore` as stored (legacy
|
|
50
|
+
* evaluations may grade on a 5-point scale; the manager app does not normalise either).
|
|
51
|
+
*/
|
|
52
|
+
export function deriveStatus(sessions, assignment, now = new Date()) {
|
|
53
|
+
const isOverdue = assignment.type === 'mandatory' && !!assignment.dueDate && new Date(assignment.dueDate) < now;
|
|
54
|
+
if (sessions.length === 0)
|
|
55
|
+
return isOverdue ? 'overdue' : 'not_started';
|
|
56
|
+
const best = bestScore(sessions);
|
|
57
|
+
if (best != null && best >= assignment.passingScore)
|
|
58
|
+
return 'passed';
|
|
59
|
+
if (isOverdue)
|
|
60
|
+
return 'overdue';
|
|
61
|
+
const trend = calcTrend(sessions);
|
|
62
|
+
if (scoredCount(sessions) >= 3 && trend != null && trend <= 0)
|
|
63
|
+
return 'needs_coaching';
|
|
64
|
+
return 'in_progress';
|
|
65
|
+
}
|
|
66
|
+
/** `getAssignmentSessions`: same scenario, a rep of the assignment, on or after it was created. */
|
|
67
|
+
export function assignmentSessions(sessions, assignment) {
|
|
68
|
+
const createdAt = new Date(assignment.createdAt).getTime();
|
|
69
|
+
return sessions.filter((s) => s.scenarioId === assignment.scenarioId &&
|
|
70
|
+
typeof s.userId === 'string' &&
|
|
71
|
+
assignment.resolvedUserIds.includes(s.userId) &&
|
|
72
|
+
new Date(s.timestamp ?? 0).getTime() >= createdAt);
|
|
73
|
+
}
|
|
74
|
+
/** `getRepStats` for one rep of one assignment, without the raw sessions. */
|
|
75
|
+
export function repStats(userId, sessions, assignment, now = new Date()) {
|
|
76
|
+
const own = sessions.filter((s) => s.userId === userId).sort(byTimestamp);
|
|
77
|
+
return {
|
|
78
|
+
user_id: userId,
|
|
79
|
+
sessions: own.length,
|
|
80
|
+
scored_sessions: scoredCount(own),
|
|
81
|
+
best_score: bestScore(own),
|
|
82
|
+
avg_score: avgScore(own),
|
|
83
|
+
trend: calcTrend(own),
|
|
84
|
+
status: deriveStatus(own, assignment, now),
|
|
85
|
+
last_session_at: own.length > 0 ? (own[own.length - 1].timestamp ?? null) : null,
|
|
86
|
+
total_duration_ms: own.reduce((sum, s) => sum + (s.durationMs ?? 0), 0),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Per-skill means over the sessions' `evaluation.category_scores`, keyed by `skillId` (the
|
|
91
|
+
* Skills Registry id). A legacy category without one falls back to its own `id`, which is stable
|
|
92
|
+
* within one scenario; an entry with neither, or with no finite score, is skipped.
|
|
93
|
+
*/
|
|
94
|
+
export function skillAverages(sessions) {
|
|
95
|
+
const bySkill = new Map();
|
|
96
|
+
for (const s of sessions) {
|
|
97
|
+
if (sessionScore(s) === null)
|
|
98
|
+
continue;
|
|
99
|
+
for (const c of s.evaluation?.category_scores ?? []) {
|
|
100
|
+
const key = c.skillId || c.id;
|
|
101
|
+
if (!key || typeof c.score !== 'number' || !Number.isFinite(c.score))
|
|
102
|
+
continue;
|
|
103
|
+
const entry = bySkill.get(key) ?? { title: c.title ?? key, scores: [] };
|
|
104
|
+
entry.scores.push(c.score);
|
|
105
|
+
bySkill.set(key, entry);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return Array.from(bySkill.entries()).map(([skill_id, { title, scores }]) => ({
|
|
109
|
+
skill_id,
|
|
110
|
+
title,
|
|
111
|
+
avg_score: meanOfFinite(scores),
|
|
112
|
+
scored_sessions: scores.length,
|
|
113
|
+
}));
|
|
114
|
+
}
|
package/dist/src/mcp/registry.js
CHANGED
|
@@ -15,6 +15,7 @@ import { metadataTemplateTools } from './tools/metadataTemplate.js';
|
|
|
15
15
|
import { ldTools } from './tools/ld.js';
|
|
16
16
|
import { orgTools } from './tools/org.js';
|
|
17
17
|
import { publishAppTools } from './tools/publishApp.js';
|
|
18
|
+
import { roleplayTools } from './tools/roleplay.js';
|
|
18
19
|
import { rolesTools } from './tools/roles.js';
|
|
19
20
|
import { searchTools } from './tools/search.js';
|
|
20
21
|
import { sharedLinksTools } from './tools/sharedLinks.js';
|
|
@@ -39,6 +40,7 @@ export const allTools = [
|
|
|
39
40
|
...rolesTools,
|
|
40
41
|
...orgTools,
|
|
41
42
|
...analyticsTools,
|
|
43
|
+
...roleplayTools,
|
|
42
44
|
...membershipsTools,
|
|
43
45
|
...groupsTools,
|
|
44
46
|
...metadataTemplateTools,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const ANALYTICS_RENDER_POLICY = "When answering Pitcher content-analytics questions (the pitcher_analytics_* tools), return the smallest sufficient representation. Answer in words first; a visual supports the sentence, it never replaces it. Default to prose and escalate ONLY when a trigger below fires.\n\nRendering tiers (evaluate top-down, stop at the first match):\n- A single fact / yes-no / \"who/what/how many is the single X\" (e.g. \"which canvas did I view?\", \"how many viewers?\") -> 1-2 sentences with the number inline. No table, no chart.\n- A ranking request (\"top\", \"most\", \"best\", \"rank\", or \"list the highest/lowest ...\") about content, files, users, or canvases -> a markdown TABLE of the ranked rows (default the top 5-8); columns = only the metric(s) asked for. Treat \"what's my top content?\" as a ranking (a table), NOT a single-item sentence. Lead with a one-line takeaway, then the table.\n- \"over time\" / \"trend\" / \"by week/month\" -> ONE line/area chart artifact (this rule wins over the generic comparison rule below for time series).\n- An explicit visualization request (\"show a chart\", \"chart\", \"visualize\", \"graph\", \"plot\") OR a comparison across a non-time dimension -> ONE focused chart as a single self-contained HTML artifact, only the asked-for series.\n- A broad overview (\"how's everything / my content / adoption doing\", \"give me the picture\", \"dashboard\", QBR) -> a multi-panel dashboard artifact. This is the ceiling \u2014 use it rarely, only for genuinely broad asks.\n\nGuardrails (every tier):\n1. Words first \u2014 even with a chart, lead with a one-sentence answer to the literal question.\n2. Mask names, emails, and user IDs by default (User A/B/...). Reveal an identity only when the analytics tool actually returned it for the authenticated role (the verbs are role-gated); never infer authorization from the user's wording.\n3. If data is empty or trivially small, say so in prose \u2014 do not render an empty chart.\n4. If results are capped/truncated, state the cap (\"top 15 of 118\").\n5. Never show a \"vs prior period\" delta unless the data actually contains prior-period numbers.\n6. Cite the source verb (\"via analytics popularity\").\n\nDo not jump a tier without its trigger. The dashboard is opt-in by intent, never a default.";
|
|
1
|
+
export declare const ANALYTICS_RENDER_POLICY = "When answering Pitcher content-analytics or AI Roleplay questions (the pitcher_analytics_* and pitcher_roleplay_* tools), return the smallest sufficient representation. Answer in words first; a visual supports the sentence, it never replaces it. Default to prose and escalate ONLY when a trigger below fires.\n\nRendering tiers (evaluate top-down, stop at the first match):\n- A single fact / yes-no / \"who/what/how many is the single X\" (e.g. \"which canvas did I view?\", \"how many viewers?\") -> 1-2 sentences with the number inline. No table, no chart.\n- A ranking request (\"top\", \"most\", \"best\", \"rank\", or \"list the highest/lowest ...\") about content, files, users, or canvases -> a markdown TABLE of the ranked rows (default the top 5-8); columns = only the metric(s) asked for. Treat \"what's my top content?\" as a ranking (a table), NOT a single-item sentence. Lead with a one-line takeaway, then the table.\n- \"over time\" / \"trend\" / \"by week/month\" -> ONE line/area chart artifact (this rule wins over the generic comparison rule below for time series).\n- An explicit visualization request (\"show a chart\", \"chart\", \"visualize\", \"graph\", \"plot\") OR a comparison across a non-time dimension -> ONE focused chart as a single self-contained HTML artifact, only the asked-for series.\n- A broad overview (\"how's everything / my content / adoption doing\", \"give me the picture\", \"dashboard\", QBR) -> a multi-panel dashboard artifact. This is the ceiling \u2014 use it rarely, only for genuinely broad asks.\n\nGuardrails (every tier):\n1. Words first \u2014 even with a chart, lead with a one-sentence answer to the literal question.\n2. Mask names, emails, and user IDs by default (User A/B/...). Reveal an identity only when the analytics tool actually returned it for the authenticated role (the verbs are role-gated); never infer authorization from the user's wording.\n3. If data is empty or trivially small, say so in prose \u2014 do not render an empty chart.\n4. If results are capped/truncated, state the cap (\"top 15 of 118\").\n5. Never show a \"vs prior period\" delta unless the data actually contains prior-period numbers.\n6. Cite the source verb (\"via analytics popularity\").\n7. AI Roleplay: a session with no score (null) is unscored, never a 0; never fold it into an average, a pass rate or a ranking.\n\nDo not jump a tier without its trigger. The dashboard is opt-in by intent, never a default.";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// question gets a right-sized reply (a sentence, a table, one chart, or — rarely — a dashboard)
|
|
4
4
|
// instead of a rigid full dashboard on every question. This constant is the single source of truth;
|
|
5
5
|
// the Docs Portal spec (Product/Specs/2026-07-24-analytics-answer-rendering-policy.md) mirrors it.
|
|
6
|
-
export const ANALYTICS_RENDER_POLICY = `When answering Pitcher content-analytics questions (the pitcher_analytics_* tools), return the smallest sufficient representation. Answer in words first; a visual supports the sentence, it never replaces it. Default to prose and escalate ONLY when a trigger below fires.
|
|
6
|
+
export const ANALYTICS_RENDER_POLICY = `When answering Pitcher content-analytics or AI Roleplay questions (the pitcher_analytics_* and pitcher_roleplay_* tools), return the smallest sufficient representation. Answer in words first; a visual supports the sentence, it never replaces it. Default to prose and escalate ONLY when a trigger below fires.
|
|
7
7
|
|
|
8
8
|
Rendering tiers (evaluate top-down, stop at the first match):
|
|
9
9
|
- A single fact / yes-no / "who/what/how many is the single X" (e.g. "which canvas did I view?", "how many viewers?") -> 1-2 sentences with the number inline. No table, no chart.
|
|
@@ -19,5 +19,6 @@ Guardrails (every tier):
|
|
|
19
19
|
4. If results are capped/truncated, state the cap ("top 15 of 118").
|
|
20
20
|
5. Never show a "vs prior period" delta unless the data actually contains prior-period numbers.
|
|
21
21
|
6. Cite the source verb ("via analytics popularity").
|
|
22
|
+
7. AI Roleplay: a session with no score (null) is unscored, never a 0; never fold it into an average, a pass rate or a ranking.
|
|
22
23
|
|
|
23
24
|
Do not jump a tier without its trigger. The dashboard is opt-in by intent, never a default.`;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { ResourceDescriptor } from './types.js';
|
|
2
|
-
export declare const ANALYTICS_CATALOG = "# Pitcher analytics catalog\n\
|
|
2
|
+
export declare const ANALYTICS_CATALOG = "# Pitcher analytics catalog\n\nFourteen read-only verbs, each an MCP tool named `pitcher_analytics_<verb>`. Pick the verb that matches the\nquestion, then call its tool. All are read-only; org scope + auth come from the server's API key (never a\ntool input) \u2014 you only pass `instanceId` (and, where noted, `canvasId`/`userEmail`).\n\n## Key tiers\n- **org-admin key required:** org, popularity, users, files, canvases\n- **instance-admin or org-admin:** instance\n- **member key OK (access follows the key user's real role):** kpi, engagement, viewers, sessions, viewer_visits\n- **instance editor, instance admin or org admin:** file_users, file_pages, canvas_users\n\n## Time model\n- **Rollup (no window):** org, instance \u2014 lifetime/current totals.\n- **Date-range, 30-day default:** kpi (activity types need dates), engagement, popularity, `users` with\n `stats: true`, files, canvases \u2014 omit dates to get the last 30 days. (`users` without `stats` is a\n full roster with no window.) Also file_users, file_pages, canvas_users.\n- **Date-range, server default window:** viewers, sessions, viewer_visits \u2014 omit dates and the service picks the window.\n\n## Verbs\n\n| Verb (tool) | Returns | Time | Key | Example question |\n|---|---|---|---|---|\n| `org` | Organization-wide rollup totals (Django) | rollup | org-admin | \"How is the whole org doing overall?\" |\n| `instance` | Single-instance rollup totals (Django) | rollup | instance-admin | \"Totals for this one workspace?\" |\n| `kpi` | KPI metrics for an instance (members, active users, \u2026) | date-range | member | \"How many active users last month?\" |\n| `engagement` | Per-DSR/content engagement rows (capped list + summary) | date-range | member | \"Which content is being engaged with?\" |\n| `popularity` | Most-viewed canvases, ranked (view-analysis) | date-range | org-admin | \"Top 10 most-viewed decks this quarter?\" |\n| `users` | User roster, or active-user stats with `stats: true` | roster: none / stats: date-range | org-admin | \"Who are my active users this month?\" |\n| `files` | File usage analytics (views/opens/engagement per file) | date-range | org-admin | \"Which files get opened the most?\" |\n| `canvases` | Per-section canvas engagement list | date-range | org-admin | \"How are my canvas sections performing?\" |\n| `viewers` | Who viewed a specific DSR canvas (per-viewer engagement) | date-range | member | \"Who viewed this DSR, and how much?\" |\n| `sessions` | One viewer's session event timeline for a DSR canvas (raw) | date-range | member | \"What did this viewer do inside the DSR?\" |\n| `file_users` | Who opened ONE file: opens, seconds, days active per user | date-range | editor | \"Who opened the pricing PDF, and for how long?\" |\n| `file_pages` | Time per page of ONE file (one user with a single `userEmails`) | date-range | editor | \"Which pages of this file do reps spend time on?\" |\n| `canvas_users` | Per user and canvas: visits, seconds; with `canvasId`, seconds per page (section) | date-range | editor | \"Which reps presented this deck, and which slides?\" |\n| `viewer_visits` | Per DSR viewer and visit: sections, files and pages with seconds; `format: csv` to export | date-range | member | \"Export who looked at which pages of this DSR\" |\n\n## Shaping\nRow-heavy verbs (engagement, popularity, users, files, canvases, viewers) return a capped envelope:\n`summary` + `rows` (default 20, max 100 \u2014 raise with `limit`) + `total_count`/`returned`/`has_more`,\nand a `truncated` note when more rows exist. `kpi` and `sessions` return the raw payload (a KPI scalar set\nand an uncapped event timeline, respectively) \u2014 never truncated.\n\n## DSR drill-down\nFor \"who viewed X and what did they do\": call `viewers` (with `canvasId`) to list viewers, then `sessions`\n(with the same `canvasId` + the viewer's `userEmail`) to see that viewer's event timeline.\nA session is one `Entered` of the queried id itself (a canvas or a section), paired by `event_pair_id`; an\n`Entered` with an empty `event_pair_id` counts as one session on its own.\n`session_count` is counted the same way as the overview's `total_sessions`. `canvases` lists sections, and their\nids are valid `canvasId` inputs for `viewers` and `sessions`.\n\n`viewer_visits` does both steps for up to 25 viewers at once and returns per-visit, per-page time; use it for\n\"which pages did each viewer look at\", and `format: \"csv\"` when the user wants a file.\n\n## Per-user usage\n\"Which files did this user open\" is `files` with `userEmails`; \"who opened this file\" is `file_users`; time per page\nof a file is `file_pages`; reps' canvas usage is `canvas_users` (internal users only \u2014 external DSR viewers are\n`viewers` / `viewer_visits`).\n\n## AI Roleplay\nRoleplay results are separate tools (`pitcher_roleplay_*`): kpis, sessions and rep_summary for instance or org admins,\nteam (assignment completion; admins see every assignment, coaching managers their own), session_get and\nscenarios_list. A session with no score is unscored, never 0.\n\n## Notes\n- `popularity` maps to the canvases view-analysis endpoint (there is no separate popularity route).\n- Results can include user identities/emails \u2014 treat as customer data and handle per policy.\n";
|
|
3
3
|
export declare const analyticsCatalogResource: ResourceDescriptor;
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
// A model-facing catalog of the analytics verbs exposed as pitcher_analytics_* tools. It exists so a
|
|
2
2
|
// host can read ONE resource to learn which verb answers which question, the time model each uses, and
|
|
3
|
-
// the key tier each needs — instead of guessing from
|
|
3
|
+
// the key tier each needs — instead of guessing from 14 tool descriptions. Distilled from the analytics
|
|
4
4
|
// runner contracts in src/commands/analytics.ts (the authoritative source); keep in sync on route/gate
|
|
5
5
|
// changes. Definitions mirror the platform analytics vocabulary.
|
|
6
6
|
export const ANALYTICS_CATALOG = `# Pitcher analytics catalog
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Fourteen read-only verbs, each an MCP tool named \`pitcher_analytics_<verb>\`. Pick the verb that matches the
|
|
9
9
|
question, then call its tool. All are read-only; org scope + auth come from the server's API key (never a
|
|
10
10
|
tool input) — you only pass \`instanceId\` (and, where noted, \`canvasId\`/\`userEmail\`).
|
|
11
11
|
|
|
12
12
|
## Key tiers
|
|
13
13
|
- **org-admin key required:** org, popularity, users, files, canvases
|
|
14
14
|
- **instance-admin or org-admin:** instance
|
|
15
|
-
- **member key OK (access follows the key user's real role):** kpi, engagement, viewers, sessions
|
|
15
|
+
- **member key OK (access follows the key user's real role):** kpi, engagement, viewers, sessions, viewer_visits
|
|
16
|
+
- **instance editor, instance admin or org admin:** file_users, file_pages, canvas_users
|
|
16
17
|
|
|
17
18
|
## Time model
|
|
18
19
|
- **Rollup (no window):** org, instance — lifetime/current totals.
|
|
19
20
|
- **Date-range, 30-day default:** kpi (activity types need dates), engagement, popularity, \`users\` with
|
|
20
21
|
\`stats: true\`, files, canvases — omit dates to get the last 30 days. (\`users\` without \`stats\` is a
|
|
21
|
-
full roster with no window.)
|
|
22
|
-
- **Date-range, server default window:** viewers, sessions — omit dates and the service picks the window.
|
|
22
|
+
full roster with no window.) Also file_users, file_pages, canvas_users.
|
|
23
|
+
- **Date-range, server default window:** viewers, sessions, viewer_visits — omit dates and the service picks the window.
|
|
23
24
|
|
|
24
25
|
## Verbs
|
|
25
26
|
|
|
@@ -35,6 +36,10 @@ tool input) — you only pass \`instanceId\` (and, where noted, \`canvasId\`/\`u
|
|
|
35
36
|
| \`canvases\` | Per-section canvas engagement list | date-range | org-admin | "How are my canvas sections performing?" |
|
|
36
37
|
| \`viewers\` | Who viewed a specific DSR canvas (per-viewer engagement) | date-range | member | "Who viewed this DSR, and how much?" |
|
|
37
38
|
| \`sessions\` | One viewer's session event timeline for a DSR canvas (raw) | date-range | member | "What did this viewer do inside the DSR?" |
|
|
39
|
+
| \`file_users\` | Who opened ONE file: opens, seconds, days active per user | date-range | editor | "Who opened the pricing PDF, and for how long?" |
|
|
40
|
+
| \`file_pages\` | Time per page of ONE file (one user with a single \`userEmails\`) | date-range | editor | "Which pages of this file do reps spend time on?" |
|
|
41
|
+
| \`canvas_users\` | Per user and canvas: visits, seconds; with \`canvasId\`, seconds per page (section) | date-range | editor | "Which reps presented this deck, and which slides?" |
|
|
42
|
+
| \`viewer_visits\` | Per DSR viewer and visit: sections, files and pages with seconds; \`format: csv\` to export | date-range | member | "Export who looked at which pages of this DSR" |
|
|
38
43
|
|
|
39
44
|
## Shaping
|
|
40
45
|
Row-heavy verbs (engagement, popularity, users, files, canvases, viewers) return a capped envelope:
|
|
@@ -50,6 +55,19 @@ A session is one \`Entered\` of the queried id itself (a canvas or a section), p
|
|
|
50
55
|
\`session_count\` is counted the same way as the overview's \`total_sessions\`. \`canvases\` lists sections, and their
|
|
51
56
|
ids are valid \`canvasId\` inputs for \`viewers\` and \`sessions\`.
|
|
52
57
|
|
|
58
|
+
\`viewer_visits\` does both steps for up to 25 viewers at once and returns per-visit, per-page time; use it for
|
|
59
|
+
"which pages did each viewer look at", and \`format: "csv"\` when the user wants a file.
|
|
60
|
+
|
|
61
|
+
## Per-user usage
|
|
62
|
+
"Which files did this user open" is \`files\` with \`userEmails\`; "who opened this file" is \`file_users\`; time per page
|
|
63
|
+
of a file is \`file_pages\`; reps' canvas usage is \`canvas_users\` (internal users only — external DSR viewers are
|
|
64
|
+
\`viewers\` / \`viewer_visits\`).
|
|
65
|
+
|
|
66
|
+
## AI Roleplay
|
|
67
|
+
Roleplay results are separate tools (\`pitcher_roleplay_*\`): kpis, sessions and rep_summary for instance or org admins,
|
|
68
|
+
team (assignment completion; admins see every assignment, coaching managers their own), session_get and
|
|
69
|
+
scenarios_list. A session with no score is unscored, never 0.
|
|
70
|
+
|
|
53
71
|
## Notes
|
|
54
72
|
- \`popularity\` maps to the canvases view-analysis endpoint (there is no separate popularity route).
|
|
55
73
|
- Results can include user identities/emails — treat as customer data and handle per policy.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { KPI_TYPES } from '../../core/kpiTypes.js';
|
|
3
|
-
import { runAnalyticsOrg, runAnalyticsInstance, runAnalyticsKpi, runAnalyticsEngagement, runAnalyticsPopularity, runAnalyticsUsers, runAnalyticsFiles, runAnalyticsCanvases, runAnalyticsViewers, runAnalyticsSessions, } from '../../commands/analytics.js';
|
|
3
|
+
import { runAnalyticsOrg, runAnalyticsInstance, runAnalyticsKpi, runAnalyticsEngagement, runAnalyticsPopularity, runAnalyticsUsers, runAnalyticsFiles, runAnalyticsCanvases, runAnalyticsViewers, runAnalyticsSessions, runAnalyticsFileUsers, runAnalyticsFilePages, runAnalyticsCanvasUsers, runAnalyticsViewerVisits, } from '../../commands/analytics.js';
|
|
4
4
|
export const analyticsTools = [
|
|
5
5
|
{
|
|
6
6
|
name: 'pitcher_analytics_org',
|
|
@@ -23,7 +23,7 @@ export const analyticsTools = [
|
|
|
23
23
|
title: 'Instance KPI metrics',
|
|
24
24
|
description: 'Read KPI metrics for an instance from the clickhouse analytics service (PIT-7647). ' +
|
|
25
25
|
'Access follows the signed-in user’s own role; activity-metric types need start/end dates ' +
|
|
26
|
-
'(inventory counts like total-members do not).',
|
|
26
|
+
'(inventory counts like total-members do not). Every value and growth figure is a number.',
|
|
27
27
|
inputShape: {
|
|
28
28
|
instanceId: z.string().describe('Instance ID (required)'),
|
|
29
29
|
type: z.enum(KPI_TYPES).describe('KPI type'),
|
|
@@ -97,7 +97,9 @@ export const analyticsTools = [
|
|
|
97
97
|
name: 'pitcher_analytics_files',
|
|
98
98
|
title: 'File usage analytics',
|
|
99
99
|
description: 'File usage analytics for an instance (views/opens/engagement per file), summarized and capped to ' +
|
|
100
|
-
'`limit`. Requires an org-admin key. Defaults to a 30-day window when dates are omitted.'
|
|
100
|
+
'`limit`. Requires an org-admin key. Defaults to a 30-day window when dates are omitted. ' +
|
|
101
|
+
'Pass userEmails to answer "which files did this user open"; for "who opened this file" use ' +
|
|
102
|
+
'pitcher_analytics_file_users.',
|
|
101
103
|
inputShape: {
|
|
102
104
|
instanceId: z.string().describe('Instance ID (required)'),
|
|
103
105
|
startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
|
|
@@ -173,4 +175,87 @@ export const analyticsTools = [
|
|
|
173
175
|
run: runAnalyticsSessions,
|
|
174
176
|
readOnly: true,
|
|
175
177
|
},
|
|
178
|
+
{
|
|
179
|
+
name: 'pitcher_analytics_file_users',
|
|
180
|
+
title: 'Who opened a file',
|
|
181
|
+
description: 'Who opened ONE file in an instance: per user (email, name, role) the opens, seconds, days active and the ' +
|
|
182
|
+
'last day opened, sorted by opens and capped to `limit` (total_count/has_more state the cap). Internal users ' +
|
|
183
|
+
'only. pageNumber (0-based) narrows to one page of the file. Needs an instance editor, instance admin or org ' +
|
|
184
|
+
'admin. Defaults to a 30-day window. For time per page use pitcher_analytics_file_pages.',
|
|
185
|
+
inputShape: {
|
|
186
|
+
instanceId: z.string().describe('Instance ID (required)'),
|
|
187
|
+
fileId: z.string().describe('File ID (required)'),
|
|
188
|
+
startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
|
|
189
|
+
endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
|
|
190
|
+
pageNumber: z.number().int().min(0).optional().describe('Only this page of the file (0-based)'),
|
|
191
|
+
groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
|
|
192
|
+
userEmails: z.string().optional().describe('Comma-separated user emails to scope by'),
|
|
193
|
+
limit: z.number().int().optional().describe('Max rows to return (default 20, max 100)'),
|
|
194
|
+
},
|
|
195
|
+
run: runAnalyticsFileUsers,
|
|
196
|
+
readOnly: true,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'pitcher_analytics_file_pages',
|
|
200
|
+
title: 'Time per page of a file',
|
|
201
|
+
description: 'Time per page of ONE file: per page (1-based page_number) the internal opens, internal seconds and mean ' +
|
|
202
|
+
'seconds, plus external (DSR) views. Pass a single userEmails value for one user\'s time per page. Every page ' +
|
|
203
|
+
'is returned. Needs an instance editor, instance admin or org admin. Defaults to a 30-day window.',
|
|
204
|
+
inputShape: {
|
|
205
|
+
instanceId: z.string().describe('Instance ID (required)'),
|
|
206
|
+
fileId: z.string().describe('File ID (required)'),
|
|
207
|
+
startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
|
|
208
|
+
endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
|
|
209
|
+
groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
|
|
210
|
+
userEmails: z.string().optional().describe('Comma-separated user emails to scope by (one email: that user)'),
|
|
211
|
+
},
|
|
212
|
+
run: runAnalyticsFilePages,
|
|
213
|
+
readOnly: true,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: 'pitcher_analytics_canvas_users',
|
|
217
|
+
title: 'Per-user canvas usage',
|
|
218
|
+
description: 'Per-user canvas usage in an instance: one row per user and canvas (who, canvas_id, canvas_name, views = ' +
|
|
219
|
+
'visits, total_seconds, last_viewed_at), most visits first, `limit` rows per page with total_count/has_more. ' +
|
|
220
|
+
'With canvasId, each row also lists the pages (sections) of that canvas the user viewed with their seconds. ' +
|
|
221
|
+
'Internal users only (external DSR viewers: pitcher_analytics_viewer_visits); presentation time only, not ' +
|
|
222
|
+
'editing. Needs an instance editor, instance admin or org admin. Defaults to a 30-day window.',
|
|
223
|
+
inputShape: {
|
|
224
|
+
instanceId: z.string().describe('Instance ID (required)'),
|
|
225
|
+
canvasId: z.string().optional().describe("One canvas: adds each user's seconds per page (section)"),
|
|
226
|
+
startDate: z.string().optional().describe('Window start, ISO date (defaults to 30 days ago)'),
|
|
227
|
+
endDate: z.string().optional().describe('Window end, ISO date (defaults to today)'),
|
|
228
|
+
groupIds: z.string().optional().describe('Comma-separated distribution-group IDs'),
|
|
229
|
+
userEmails: z.string().optional().describe('Comma-separated user emails to scope by'),
|
|
230
|
+
page: z.number().int().optional().describe('Page number (default 1)'),
|
|
231
|
+
limit: z.number().int().optional().describe('Rows per page (default 20, max 100)'),
|
|
232
|
+
},
|
|
233
|
+
run: runAnalyticsCanvasUsers,
|
|
234
|
+
readOnly: true,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'pitcher_analytics_viewer_visits',
|
|
238
|
+
title: 'DSR viewer visits and page time',
|
|
239
|
+
description: 'Per viewer of a DSR canvas: each visit (start, engaged seconds) and in it the sections, files and pages ' +
|
|
240
|
+
'viewed with views and seconds (`estimated: true` where an exit event was missing and the time is ' +
|
|
241
|
+
'inferred). One viewer (userEmail, verbatim from pitcher_analytics_viewers) or the most recently active ' +
|
|
242
|
+
'viewers (limit, default 10, max 25; viewers_total and truncated state the cap). format "csv" returns the ' +
|
|
243
|
+
'same as CSV text in `csv` (one row per viewer, visit and item) with a suggested filename, for export: hand ' +
|
|
244
|
+
'it to the user as a file. Works for any member. The server applies its own default window.',
|
|
245
|
+
inputShape: {
|
|
246
|
+
instanceId: z.string().describe('Instance ID (required)'),
|
|
247
|
+
canvasId: z.string().describe('DSR canvas ID (required)'),
|
|
248
|
+
userEmail: z
|
|
249
|
+
.string()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe('One viewer: normalized_email (or email) from pitcher_analytics_viewers, VERBATIM'),
|
|
252
|
+
viewerType: z.string().optional().describe("Viewer type when listing: all, internal, external (default 'all')"),
|
|
253
|
+
startDate: z.string().optional().describe('Window start, ISO date (server default applies if omitted)'),
|
|
254
|
+
endDate: z.string().optional().describe('Window end, ISO date (server default applies if omitted)'),
|
|
255
|
+
limit: z.number().int().optional().describe('Viewers to read when no userEmail (default 10, max 25)'),
|
|
256
|
+
format: z.enum(['json', 'csv']).optional().describe('json (default) or csv for an export'),
|
|
257
|
+
},
|
|
258
|
+
run: runAnalyticsViewerVisits,
|
|
259
|
+
readOnly: true,
|
|
260
|
+
},
|
|
176
261
|
];
|