@pitcher/cli-scripts 3.1.0 → 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.
@@ -0,0 +1,449 @@
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, 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
+ 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
+ const COACH_BASE = '/core/api/protected/roleplay/coach';
22
+ const APPSDB_QUERY = '/core/api/protected/appsdb/query';
23
+ function requireInstance(opts, verb) {
24
+ if (!opts.instanceId)
25
+ throw createCliError('VALIDATION', `--instance-id is required for roleplay ${verb}`);
26
+ return opts.instanceId;
27
+ }
28
+ /** clickhouse-api params; `scenario` matches the persona OR scenario name server-side (`persona`). */
29
+ function filterParams(opts, instanceId) {
30
+ if (opts.difficulty && !ROLEPLAY_DIFFICULTIES.includes(opts.difficulty)) {
31
+ throw createCliError('VALIDATION', `--difficulty must be one of: ${ROLEPLAY_DIFFICULTIES.join(', ')}`);
32
+ }
33
+ const params = { instance_id: instanceId };
34
+ if (opts.startDate)
35
+ params.start_date = opts.startDate;
36
+ if (opts.endDate)
37
+ params.end_date = opts.endDate;
38
+ if (opts.rep)
39
+ params.rep = opts.rep;
40
+ if (opts.scenario)
41
+ params.persona = opts.scenario;
42
+ if (opts.difficulty)
43
+ params.difficulty = opts.difficulty;
44
+ return params;
45
+ }
46
+ /** Dates have NO default window here (unlike the content-analytics verbs): omitted = all time. */
47
+ function windowOf(opts) {
48
+ return { start_date: opts.startDate ?? null, end_date: opts.endDate ?? null };
49
+ }
50
+ const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
51
+ /** clickhouse-api's roleplay routes are instance/org-admin only: say so, and where a manager goes instead. */
52
+ function adminOnlyError(e) {
53
+ const err = toCliError(e);
54
+ if (err.code !== 'AUTH')
55
+ return err;
56
+ return createCliError('AUTH', `${err.message} — roleplay analytics are for instance or org admins; a coaching manager can use roleplay team`);
57
+ }
58
+ // ── kpis ─────────────────────────────────────────────────────────────────────
59
+ /** Totals for the window: sessions, scored/unscored, mean score over SCORED sessions only, active reps. */
60
+ export async function runRoleplayKpis(opts, deps = {}) {
61
+ try {
62
+ const instanceId = requireInstance(opts, 'kpis');
63
+ const params = filterParams(opts, instanceId);
64
+ const client = clickhouseClientFor(opts, deps);
65
+ const res = await client.get('/analytics/roleplay/kpis', { params });
66
+ return ok({ ...res.data, window: windowOf(opts) });
67
+ }
68
+ catch (e) {
69
+ return fail(adminOnlyError(e));
70
+ }
71
+ }
72
+ // ── sessions ─────────────────────────────────────────────────────────────────
73
+ const SESSION_FIELDS = [
74
+ 'entry_id',
75
+ 'session_at',
76
+ 'rep_name',
77
+ 'who',
78
+ 'scenario_name',
79
+ 'persona_name',
80
+ 'difficulty',
81
+ 'score',
82
+ 'evaluated',
83
+ 'rating',
84
+ 'duration_ms',
85
+ ];
86
+ /** The row allowlist: nothing else from the replica (provider, recording flags, rubric) leaves. */
87
+ export function pickSessionRow(row) {
88
+ const out = {};
89
+ for (const f of SESSION_FIELDS)
90
+ out[f] = row[f] ?? null;
91
+ out.score = finiteOrNull(row.score);
92
+ return out;
93
+ }
94
+ export async function runRoleplaySessions(opts, deps = {}) {
95
+ try {
96
+ const instanceId = requireInstance(opts, 'sessions');
97
+ if (opts.sortBy && !ROLEPLAY_SESSION_SORT.includes(opts.sortBy)) {
98
+ throw createCliError('VALIDATION', `--sort-by must be one of: ${ROLEPLAY_SESSION_SORT.join(', ')}`);
99
+ }
100
+ const sortOrder = opts.sortOrder?.toLowerCase();
101
+ if (sortOrder && sortOrder !== 'asc' && sortOrder !== 'desc') {
102
+ throw createCliError('VALIDATION', '--sort-order must be asc or desc');
103
+ }
104
+ const page = Number.isInteger(opts.page) && opts.page > 0 ? opts.page : 1;
105
+ const pageSize = pageSizeFor(opts.limit);
106
+ const params = { ...filterParams(opts, instanceId), page, page_size: pageSize };
107
+ if (opts.sortBy)
108
+ params.sort_by = opts.sortBy;
109
+ if (sortOrder)
110
+ params.sort_order = sortOrder;
111
+ const client = clickhouseClientFor(opts, deps);
112
+ const res = await client.get('/analytics/roleplay/sessions', { params });
113
+ const body = (res.data ?? {});
114
+ const rows = (Array.isArray(body.data) ? body.data : []).slice(0, pageSize).map(pickSessionRow);
115
+ const total = typeof body.total === 'number' ? body.total : rows.length;
116
+ const shown = (page - 1) * pageSize + rows.length;
117
+ const hasMore = shown < total;
118
+ return ok({
119
+ rows,
120
+ total_count: total,
121
+ returned: rows.length,
122
+ page,
123
+ has_more: hasMore,
124
+ ...(hasMore
125
+ ? { truncated: `Showing ${rows.length} of ${total.toLocaleString('en-US')} (page ${page}) — pass page=${page + 1} or narrow with filters` }
126
+ : {}),
127
+ window: windowOf(opts),
128
+ });
129
+ }
130
+ catch (e) {
131
+ return fail(adminOnlyError(e));
132
+ }
133
+ }
134
+ const round1 = (n) => Math.round(n * 10) / 10;
135
+ /**
136
+ * Per-rep (keyed on the rep email `who`) or per-scenario (keyed on `scenario_id`) aggregates over
137
+ * the sessions list, read in pages of 1000 (newest first) up to 5000. `complete: false` when the
138
+ * window holds more than that; then the figures cover the newest 5000 only.
139
+ */
140
+ export async function runRoleplayRepSummary(opts, deps = {}) {
141
+ try {
142
+ const instanceId = requireInstance(opts, 'rep-summary');
143
+ const groupBy = opts.groupBy ?? 'rep';
144
+ if (!ROLEPLAY_GROUP_BY.includes(groupBy)) {
145
+ throw createCliError('VALIDATION', `--group-by must be one of: ${ROLEPLAY_GROUP_BY.join(', ')}`);
146
+ }
147
+ const base = filterParams(opts, instanceId);
148
+ const client = clickhouseClientFor(opts, deps);
149
+ const fetched = [];
150
+ let total = 0;
151
+ for (let page = 1; page <= REP_SUMMARY_MAX_PAGES; page += 1) {
152
+ const res = await client.get('/analytics/roleplay/sessions', {
153
+ params: { ...base, page, page_size: REP_SUMMARY_PAGE_SIZE, sort_by: 'session_at', sort_order: 'desc' },
154
+ });
155
+ const body = (res.data ?? {});
156
+ const batch = Array.isArray(body.data) ? body.data : [];
157
+ total = typeof body.total === 'number' ? body.total : fetched.length + batch.length;
158
+ fetched.push(...batch);
159
+ if (batch.length < REP_SUMMARY_PAGE_SIZE || fetched.length >= total)
160
+ break;
161
+ }
162
+ const groups = new Map();
163
+ for (const row of fetched) {
164
+ const key = String((groupBy === 'rep' ? row.who : row.scenario_id) ?? '');
165
+ const name = String((groupBy === 'rep' ? row.rep_name || row.who : row.scenario_name || row.persona_name) ?? '');
166
+ const g = groups.get(key) ?? { name, sessions: 0, scores: [], last: null };
167
+ g.sessions += 1;
168
+ const score = finiteOrNull(row.score);
169
+ if (score !== null)
170
+ g.scores.push(score);
171
+ const at = typeof row.session_at === 'string' ? row.session_at : null;
172
+ if (at && (!g.last || at > g.last))
173
+ g.last = at;
174
+ groups.set(key, g);
175
+ }
176
+ const all = Array.from(groups.entries())
177
+ .map(([key, g]) => ({
178
+ key,
179
+ name: g.name,
180
+ sessions: g.sessions,
181
+ scored_sessions: g.scores.length,
182
+ avg_score: g.scores.length ? round1(g.scores.reduce((a, b) => a + b, 0) / g.scores.length) : null,
183
+ last_session_at: g.last,
184
+ }))
185
+ .sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key));
186
+ const shown = all.slice(0, REP_SUMMARY_MAX_GROUPS);
187
+ const complete = fetched.length >= total;
188
+ const warnings = [];
189
+ if (!complete) {
190
+ warnings.push(`Covers the newest ${fetched.length.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} sessions; narrow the dates for exact figures`);
191
+ }
192
+ return ok({
193
+ group_by: groupBy,
194
+ key_field: groupBy === 'rep' ? 'who (rep email)' : 'scenario_id',
195
+ groups: shown,
196
+ total_groups: all.length,
197
+ returned: shown.length,
198
+ sessions_read: fetched.length,
199
+ total_sessions: total,
200
+ complete,
201
+ ...(all.length > shown.length
202
+ ? { truncated: `Showing the ${shown.length} ${groupBy === 'rep' ? 'reps' : 'scenarios'} with most sessions of ${all.length}` }
203
+ : {}),
204
+ ...(warnings.length ? { warnings } : {}),
205
+ window: windowOf(opts),
206
+ });
207
+ }
208
+ catch (e) {
209
+ return fail(adminOnlyError(e));
210
+ }
211
+ }
212
+ async function getCoachBundle(client, instanceId) {
213
+ const headers = { 'x-instance-id': instanceId };
214
+ try {
215
+ const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId, scope: 'instance' }, headers });
216
+ return { bundle: (res.data ?? {}), requested: 'instance' };
217
+ }
218
+ catch (e) {
219
+ // Not an instance or org admin: fall back to the caller's own coaching assignments.
220
+ if (toCliError(e).code !== 'AUTH')
221
+ throw e;
222
+ const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId }, headers });
223
+ return { bundle: (res.data ?? {}), requested: 'coach' };
224
+ }
225
+ }
226
+ /**
227
+ * Assignment completion per rep, ported from the manager app: an admin gets every roleplay
228
+ * assignment on the instance (`scope=instance`), anyone else the assignments they coach.
229
+ * Statuses follow `deriveStatus`; raw sessions, evaluations and comments never leave.
230
+ */
231
+ export async function runRoleplayTeam(opts, deps = {}) {
232
+ try {
233
+ const instanceId = requireInstance(opts, 'team');
234
+ if (opts.status && !REP_STATUSES.includes(opts.status)) {
235
+ throw createCliError('VALIDATION', `--status must be one of: ${REP_STATUSES.join(', ')}`);
236
+ }
237
+ const client = clientFor(opts, deps);
238
+ const { bundle, requested } = await getCoachBundle(client, instanceId);
239
+ // An older next-core ignores scope=instance and answers without `scope`: report what was applied.
240
+ const scope = bundle.scope === 'instance' && requested === 'instance' ? 'instance' : 'coach';
241
+ const sessions = (bundle.sessions ?? []).filter((s) => s.sessionType !== 'manager-video-practice');
242
+ const roleplayAssignments = (bundle.assignments ?? []).filter((a) => a.contentType !== 'practice');
243
+ const selected = opts.assignmentId ? roleplayAssignments.filter((a) => a.id === opts.assignmentId) : roleplayAssignments;
244
+ if (opts.assignmentId && selected.length === 0) {
245
+ throw createCliError('NOT_FOUND', `No roleplay assignment ${opts.assignmentId} in this ${scope === 'instance' ? 'instance' : 'coaching scope'}`);
246
+ }
247
+ const now = new Date();
248
+ const shownAssignments = selected.slice(0, TEAM_MAX_ASSIGNMENTS);
249
+ const assignments = shownAssignments.map((a) => {
250
+ const own = assignmentSessions(sessions, a);
251
+ const reps = (a.resolvedUserIds ?? []).map((id) => ({
252
+ ...repStats(id, own, a, now),
253
+ name: a.resolvedUserNames?.[id] ?? null,
254
+ }));
255
+ const statusCounts = Object.fromEntries(REP_STATUSES.map((st) => [st, reps.filter((r) => r.status === st).length]));
256
+ const filtered = opts.status ? reps.filter((r) => r.status === opts.status) : reps;
257
+ const shownReps = filtered.slice(0, TEAM_MAX_REPS_PER_ASSIGNMENT);
258
+ return {
259
+ id: a.id,
260
+ scenario_id: a.scenarioId,
261
+ scenario_name: a.scenarioName ?? null,
262
+ type: a.type,
263
+ passing_score: a.passingScore,
264
+ due_date: a.dueDate,
265
+ active: a.active !== false,
266
+ created_at: a.createdAt,
267
+ rep_count: reps.length,
268
+ status_counts: statusCounts,
269
+ skills: skillAverages(own),
270
+ reps: shownReps,
271
+ ...(filtered.length > shownReps.length
272
+ ? { truncated: `Showing ${shownReps.length} of ${filtered.length} reps — filter with status` }
273
+ : {}),
274
+ };
275
+ });
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
+ return ok({
282
+ scope,
283
+ complete: !capped,
284
+ assignments,
285
+ total_assignments: selected.length,
286
+ returned: assignments.length,
287
+ ...(selected.length > assignments.length
288
+ ? { truncated: `Showing ${assignments.length} of ${selected.length} assignments — pass assignmentId` }
289
+ : {}),
290
+ ...(warnings.length ? { warnings } : {}),
291
+ });
292
+ }
293
+ catch (e) {
294
+ return fail(toCliError(e));
295
+ }
296
+ }
297
+ /**
298
+ * The allowlist. Never the transcript, messages, contact name, graded snapshot, voice session,
299
+ * recording or audio ids, or any video-evaluation field.
300
+ */
301
+ export function pickSessionDetail(id, data) {
302
+ const evaluation = (data.evaluation ?? null);
303
+ const categories = Array.isArray(evaluation?.category_scores) ? evaluation.category_scores : [];
304
+ const score = sessionScore(data);
305
+ return {
306
+ id,
307
+ scenario_name: data.scenarioName ?? null,
308
+ difficulty: data.difficulty ?? null,
309
+ timestamp: data.timestamp ?? null,
310
+ duration_ms: finiteOrNull(data.durationMs),
311
+ overall_score: score,
312
+ scored: score !== null,
313
+ evaluation_status: data.evaluationStatus ?? null,
314
+ evaluation: evaluation
315
+ ? {
316
+ summary: evaluation.summary ?? null,
317
+ strengths: Array.isArray(evaluation.strengths) ? evaluation.strengths : [],
318
+ improvements: Array.isArray(evaluation.improvements) ? evaluation.improvements : [],
319
+ category_scores: categories.map((c) => ({
320
+ skill_id: c.skillId ?? null,
321
+ title: c.title ?? null,
322
+ score: finiteOrNull(c.score),
323
+ feedback: c.feedback ?? null,
324
+ })),
325
+ }
326
+ : null,
327
+ };
328
+ }
329
+ export async function runRoleplaySessionGet(opts, deps = {}) {
330
+ try {
331
+ const instanceId = requireInstance(opts, 'session');
332
+ if (!opts.entryId)
333
+ throw createCliError('VALIDATION', '--entry-id is required for roleplay session');
334
+ const client = clientFor(opts, deps);
335
+ const res = await client.get(`${COACH_BASE}/sessions/${encodeURIComponent(opts.entryId)}`, {
336
+ params: { instance_id: instanceId },
337
+ headers: { 'x-instance-id': instanceId },
338
+ });
339
+ const body = (res.data ?? {});
340
+ return ok(pickSessionDetail(String(body.id ?? opts.entryId), body.data ?? {}));
341
+ }
342
+ catch (e) {
343
+ if (isCliError(e))
344
+ return fail(e);
345
+ const err = toCliError(e);
346
+ if (err.code === 'AUTH') {
347
+ return fail(createCliError('AUTH', `${err.message} — a session opens to its rep, a coach of that rep, or an instance or org admin`));
348
+ }
349
+ return fail(err);
350
+ }
351
+ }
352
+ // ── scenarios ────────────────────────────────────────────────────────────────
353
+ /** The trimmed catalog: never the persona prompt or the evaluation prompt. */
354
+ export function pickScenario(s) {
355
+ const persona = (s.persona ?? null);
356
+ const assessment = (s.assessment ?? null);
357
+ return {
358
+ id: s.id ?? null,
359
+ kind: s.kind === 'multi' ? 'multi' : 'single',
360
+ name: (persona?.name ?? s.name ?? null),
361
+ persona_title: persona?.title ?? null,
362
+ description: s.description ?? null,
363
+ difficulty: s.difficulty ?? 'medium',
364
+ active: s.active !== false,
365
+ time_limit_minutes: s.time_limit_minutes ?? null,
366
+ categories: (assessment?.categories ?? []).map((c) => ({ title: c.title ?? null, weight: c.weight ?? null })),
367
+ };
368
+ }
369
+ export async function runRoleplayScenariosList(opts, deps = {}) {
370
+ try {
371
+ const instanceId = requireInstance(opts, 'scenarios');
372
+ const client = clientFor(opts, deps);
373
+ // Newest row only: duplicate cch-config rows exist, and the admin app reads the same one.
374
+ const res = await client.post(APPSDB_QUERY, {
375
+ type: 'cch-config',
376
+ where: { field: 'instance_id', operator: 'eq', value: instanceId },
377
+ orderBy: [{ field: 'updated_at', direction: 'desc' }],
378
+ limit: 1,
379
+ }, { headers: { 'x-instance-id': instanceId } });
380
+ const entry = (res.data ?? {}).entries?.[0];
381
+ const scenarios = Array.isArray(entry?.data?.scenarios) ? entry.data.scenarios : [];
382
+ return ok({ configured: !!entry, scenarios: scenarios.map(pickScenario), total_count: scenarios.length });
383
+ }
384
+ catch (e) {
385
+ return fail(toCliError(e));
386
+ }
387
+ }
388
+ // ── commander ────────────────────────────────────────────────────────────────
389
+ const ADMIN_NOTE = 'instance or org admins only';
390
+ function withFilterFlags(cmd) {
391
+ return cmd
392
+ .option('--instance-id <id>', 'Instance ID (required)')
393
+ .option('--start-date <iso>', 'Window start (ISO date; omitted = all time)')
394
+ .option('--end-date <iso>', 'Window end (ISO date; omitted = all time)')
395
+ .option('--rep <text>', 'Rep name or email contains')
396
+ .option('--scenario <text>', 'Scenario or persona name contains')
397
+ .option('--difficulty <level>', `Difficulty: ${ROLEPLAY_DIFFICULTIES.join(', ')}`);
398
+ }
399
+ export function createRoleplayCommand() {
400
+ const cmd = new Command('roleplay').description('read AI Roleplay sessions, scores and assignment completion');
401
+ withFilterFlags(withAuthFlags(cmd.command('kpis')))
402
+ .description(`roleplay totals: sessions, scored/unscored, mean score of scored sessions, active reps (${ADMIN_NOTE})`)
403
+ .action(async (options, command) => {
404
+ const globals = command.optsWithGlobals();
405
+ emitEnvelope(await runRoleplayKpis({ ...options, quiet: globals.quiet }), globals);
406
+ });
407
+ withFilterFlags(withAuthFlags(cmd.command('sessions')))
408
+ .option('--sort-by <field>', `Sort field: ${ROLEPLAY_SESSION_SORT.join(', ')}`)
409
+ .option('--sort-order <dir>', 'asc or desc (default desc)')
410
+ .option('--page <n>', 'Page number', (v) => parseInt(v, 10))
411
+ .option('--limit <n>', 'Rows per page (default 20, max 100)', (v) => parseInt(v, 10))
412
+ .description(`roleplay session list (${ADMIN_NOTE})`)
413
+ .action(async (options, command) => {
414
+ const globals = command.optsWithGlobals();
415
+ emitEnvelope(await runRoleplaySessions({ ...options, quiet: globals.quiet }), globals);
416
+ });
417
+ withFilterFlags(withAuthFlags(cmd.command('rep-summary')))
418
+ .option('--group-by <key>', `Group by: ${ROLEPLAY_GROUP_BY.join(', ')} (default rep)`)
419
+ .description(`per-rep or per-scenario session counts and mean scores (${ADMIN_NOTE})`)
420
+ .action(async (options, command) => {
421
+ const globals = command.optsWithGlobals();
422
+ emitEnvelope(await runRoleplayRepSummary({ ...options, quiet: globals.quiet }), globals);
423
+ });
424
+ withAuthFlags(cmd.command('team'))
425
+ .option('--instance-id <id>', 'Instance ID (required)')
426
+ .option('--assignment-id <id>', 'Only this assignment')
427
+ .option('--status <status>', `Only reps with this status: ${REP_STATUSES.join(', ')}`)
428
+ .description('assignment completion per rep: every assignment for admins, your coached ones for managers')
429
+ .action(async (options, command) => {
430
+ const globals = command.optsWithGlobals();
431
+ emitEnvelope(await runRoleplayTeam({ ...options, quiet: globals.quiet }), globals);
432
+ });
433
+ withAuthFlags(cmd.command('session'))
434
+ .option('--instance-id <id>', 'Instance ID (required)')
435
+ .option('--entry-id <id>', 'Session entry ID (required; entry_id from roleplay sessions)')
436
+ .description('one session: score, summary, strengths, improvements, per-skill scores (no transcript)')
437
+ .action(async (options, command) => {
438
+ const globals = command.optsWithGlobals();
439
+ emitEnvelope(await runRoleplaySessionGet({ ...options, quiet: globals.quiet }), globals);
440
+ });
441
+ withAuthFlags(cmd.command('scenarios'))
442
+ .option('--instance-id <id>', 'Instance ID (required)')
443
+ .description('the instance scenario catalog (names, difficulty, skills; no prompts)')
444
+ .action(async (options, command) => {
445
+ const globals = command.optsWithGlobals();
446
+ emitEnvelope(await runRoleplayScenariosList({ ...options, quiet: globals.quiet }), globals);
447
+ });
448
+ return cmd;
449
+ }
@@ -0,0 +1,50 @@
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
+ export interface DsrSessionEvent {
14
+ timestamp: string;
15
+ action: string;
16
+ entity_id: string;
17
+ entity_type?: string;
18
+ entity_name?: string;
19
+ original_event_name: string;
20
+ duration?: number;
21
+ event_pair_id?: string;
22
+ }
23
+ export interface DsrVisitItem {
24
+ type: 'section' | 'file' | 'page';
25
+ id: string;
26
+ name: string;
27
+ page_number?: number;
28
+ views: number;
29
+ seconds: number;
30
+ estimated: boolean;
31
+ }
32
+ export interface DsrVisit {
33
+ visit: number;
34
+ started_at: string;
35
+ ended_at: string;
36
+ engaged_seconds: number;
37
+ items: DsrVisitItem[];
38
+ }
39
+ /** `reconstructSessions`' grouping: events from an anchor `Entered` until the next one, by pair key. */
40
+ export declare function groupVisits(events: DsrSessionEvent[], anchorId: string): DsrSessionEvent[][];
41
+ /** `computeEngagementDuration`: the wall-clock of each canvas focus segment, summed. */
42
+ export declare function engagedSeconds(events: DsrSessionEvent[], anchorId: string): number;
43
+ export declare function summarizeVisits(events: DsrSessionEvent[], canvasId: string): DsrVisit[];
44
+ /** RFC 4180 cell; a leading = + - @ is prefixed with ' so a spreadsheet never runs it as a formula. */
45
+ export declare function csvCell(v: unknown): string;
46
+ export declare const VISITS_CSV_HEADER: string[];
47
+ export declare function visitsToCsv(viewers: Array<{
48
+ viewer: string;
49
+ visits: DsrVisit[];
50
+ }>): string;