wldx-cli 0.1.1 → 1.1.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,386 @@
1
+ // wx meetings: conferences (x-bees meetings) after the fact: the record, a readable card, the transcript, the AI
2
+ // summary and insights, who spoke when, the recording link. Read only, except schedule-summary (asks the service
3
+ // to generate the summary again). Data of WDA History and WDA Insights.
4
+ import {UsageError} from 'wldx-core/errors';
5
+ import {localTime, oneLine, table} from 'wldx-core/format';
6
+ import {apiPath, query} from 'wldx-core/http';
7
+ import {insightLines, millis, sectionLines, summaryLines} from '../history/text.mjs';
8
+ import {
9
+ NoDataError,
10
+ alt,
11
+ compactInsights,
12
+ conferenceId,
13
+ isoSeconds,
14
+ optional,
15
+ participantNames,
16
+ plural,
17
+ wdaClient,
18
+ writePrivate,
19
+ } from '../history/wda.mjs';
20
+ import {annotationLines, insightsText, stateText, structuredLines} from './calls.mjs';
21
+
22
+ const CONF = '<conference>';
23
+ const CONF_HINT = 'A conference is an id or a link https://app.x-bees.com/insights/conferences/<id>.';
24
+ export const conferenceLink = (id) => `https://app.x-bees.com/insights/conferences/${id}`;
25
+
26
+ const SUMMARY_KEYS = ['topics', 'review', 'decisions', 'progress', 'issues', 'actionItems', 'nextSteps'];
27
+
28
+ // `meetings brief`: the record, the participants (each once), the summary and the insights as one object.
29
+ export function briefConference(id, rec, sum, ins) {
30
+ const c = rec?.conference ?? {};
31
+ const groups = new Map();
32
+ for (const p of c.participants ?? []) {
33
+ const i = p.info ?? {};
34
+ const name = alt(i.name, i.email);
35
+ if (name === null) continue;
36
+ const key = String(alt(i.email, name));
37
+ if (!groups.has(key)) groups.set(key, []);
38
+ groups.get(key).push({name, email: i.email ?? null, xbsId: i.xbsId ?? null, speak: p.totalSpeakDuration ?? 0});
39
+ }
40
+ const participants = [...groups.keys()].sort().map((k) => {
41
+ const list = groups.get(k);
42
+ const {speak, ...first} = list[0];
43
+ return {...first, speakMin: Math.round(list.reduce((n, x) => n + x.speak, 0) / 60000)};
44
+ });
45
+ const s = sum?.summary ?? {};
46
+ return {
47
+ id,
48
+ subject: c.subject ?? null,
49
+ start: isoSeconds(c.startTime),
50
+ durationMin: Math.round((c.duration ?? 0) / 60000),
51
+ status: c.status ?? null,
52
+ direction: c.direction ?? null,
53
+ participants,
54
+ transcriptionStatus: c.transcriptionStatus ?? null,
55
+ transcriptionLanguage: c.transcriptionLanguage ?? null,
56
+ hasRecording: (c.recordings ?? []).length > 0,
57
+ summary:
58
+ sum?.status === 'SUCCEEDED'
59
+ ? {
60
+ title: s.title ?? null,
61
+ brief: s.brief ?? null,
62
+ ...Object.fromEntries(SUMMARY_KEYS.map((k) => [k, s.json?.[k] ?? []])),
63
+ }
64
+ : {status: sum?.status ?? 'UNKNOWN'},
65
+ insights: compactInsights(ins),
66
+ };
67
+ }
68
+
69
+ async function briefOne(wda, id, info) {
70
+ const base = apiPath`/${id}`;
71
+ const [rec, ins, sum] = await Promise.all([
72
+ wda.get(`/history/conferences${base}`),
73
+ optional(wda.get(`/insights/conferences${base}/insights`), info),
74
+ optional(wda.get(`/insights/conferences${base}/summary`), info),
75
+ ]);
76
+ return briefConference(id, rec, sum, ins);
77
+ }
78
+
79
+ // The header of a transcript: what the ADR skill and people read before the dialogue. Times in UTC.
80
+ export function transcriptHeader(id, c, lines) {
81
+ const one = (s) => String(s).replace(/[\r\n]+/g, ' ');
82
+ return [
83
+ `# ${one(c.subject || 'Conference')}`,
84
+ `# conferenceId: ${id}`,
85
+ `# url: ${conferenceLink(id)}`,
86
+ `# start: ${isoSeconds(c.startTime)}`,
87
+ `# duration: ${Math.round((c.duration ?? 0) / 60000)} min`,
88
+ `# language: ${c.transcriptionLanguage || 'unknown'}`,
89
+ `# participants: ${participantNames(c).join(', ') || 'unknown'}`,
90
+ `# lines: ${lines}`,
91
+ '',
92
+ ].join('\n');
93
+ }
94
+
95
+ const NO_TRANSCRIPT = 'This conference has no transcription, or there is no such conference.';
96
+
97
+ function briefLines(b) {
98
+ const head = [
99
+ localTime(b.start),
100
+ `${b.durationMin} min`,
101
+ b.status,
102
+ b.direction,
103
+ b.transcriptionStatus &&
104
+ `transcript ${b.transcriptionStatus}${b.transcriptionLanguage ? ` (${b.transcriptionLanguage})` : ''}`,
105
+ b.hasRecording && 'recording',
106
+ ]
107
+ .filter(Boolean)
108
+ .join(' · ');
109
+ const lines = [oneLine(b.subject || 'Conference', 200), ` ${head}`, ` ${b.id}`];
110
+ if (b.participants.length)
111
+ lines.push(
112
+ ` Participants: ${b.participants.map((p) => `${p.name}${p.speakMin ? ` (${p.speakMin} min)` : ''}`).join(', ')}`,
113
+ );
114
+ if (b.summary.status) lines.push(` Summary: ${b.summary.status}`);
115
+ else {
116
+ if (b.summary.title) lines.push(` Summary: ${oneLine(b.summary.title, 200)}`);
117
+ if (b.summary.brief) lines.push(` ${oneLine(b.summary.brief, 2000)}`);
118
+ lines.push(...sectionLines(b.summary, ' '));
119
+ }
120
+ const ins = insightLines(b.insights, ' ');
121
+ if (ins.length) lines.push(' Insights:', ...ins);
122
+ return lines;
123
+ }
124
+
125
+ const conf = (ctx) => conferenceId(ctx.positionals[0]);
126
+ const get = (ctx, suffix) =>
127
+ ctx.client(wdaClient).get(`/${suffix[0]}/conferences${apiPath`/${conf(ctx)}`}${suffix[1] ?? ''}`);
128
+
129
+ const group = {
130
+ summary: 'meetings (conferences) after the fact: record, transcript, summary, insights, recording',
131
+ auth: true,
132
+ commands: {
133
+ get: {
134
+ summary: 'the record of a meeting: subject, times, participants, recordings, transcription status',
135
+ usage: `wx meetings get ${CONF}`,
136
+ details: CONF_HINT,
137
+ options: {},
138
+ args: [1, 1],
139
+ run: (ctx) => get(ctx, ['history']),
140
+ text(r) {
141
+ const c = r.conference ?? {};
142
+ const names = participantNames(c);
143
+ return table([
144
+ ['Meeting', oneLine(c.subject || 'Conference', 200)],
145
+ ['Id', c.id ?? ''],
146
+ ['Start', `${localTime(c.startTime)} · ${millis(c.duration)}`],
147
+ ['Status', [c.status, c.direction].filter(Boolean).join(' · ')],
148
+ ['Participants', `${names.length}${names.length ? `: ${names.join(', ')}` : ''}`],
149
+ ['Transcript', [c.transcriptionStatus, c.transcriptionLanguage].filter(Boolean).join(' · ') || 'none'],
150
+ ['Recordings', String((c.recordings ?? []).length)],
151
+ ]);
152
+ },
153
+ },
154
+ brief: {
155
+ summary: 'a meeting as one readable card: record, participants, summary, insights (several: oldest first)',
156
+ usage: `wx meetings brief ${CONF}...`,
157
+ details: `${CONF_HINT} With several meetings the answer is an array, oldest first; one that fails is skipped with a warning.`,
158
+ options: {},
159
+ args: [1, 50],
160
+ async run(ctx) {
161
+ const wda = ctx.client(wdaClient);
162
+ const ids = ctx.positionals.map(conferenceId);
163
+ if (ids.length === 1) return briefOne(wda, ids[0], ctx.info);
164
+ const cards = [];
165
+ for (let i = 0; i < ids.length; i += 4) {
166
+ const batch = await Promise.allSettled(ids.slice(i, i + 4).map((id) => briefOne(wda, id, ctx.info)));
167
+ batch.forEach((r, j) => {
168
+ if (r.status === 'fulfilled') cards.push(r.value);
169
+ else ctx.info(`Warning: skipped ${ids[i + j]}: ${r.reason?.message?.split('\n')[0]}`);
170
+ });
171
+ }
172
+ if (!cards.length) throw new Error('None of the meetings could be read (see the warnings above)');
173
+ return cards.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
174
+ },
175
+ text: (r) => (Array.isArray(r) ? r.flatMap((b, i) => [...(i ? [''] : []), ...briefLines(b)]) : briefLines(r)),
176
+ },
177
+ transcript: {
178
+ summary: 'the dialogue of a meeting with a header (exit 3 when there is none)',
179
+ usage: `wx meetings transcript ${CONF} [--to FILE] [--structured]`,
180
+ details: `${CONF_HINT} Text: a header (subject, id, link, start in UTC, duration, language, participants, lines) and the dialogue "[MM:SS] Name: text". --output json: the header fields under "conference", and the text and its chunks as the service gives them. --structured: the transcription record with the interaction data. --to writes the header and the dialogue to a new private file (an existing file only when it is the transcript of the same meeting) and prints its path.`,
181
+ options: {
182
+ to: {
183
+ type: 'string',
184
+ placeholder: 'FILE',
185
+ description: 'write the transcript to this file (private) and print its path',
186
+ },
187
+ structured: {
188
+ type: 'boolean',
189
+ description: 'the transcription record: chunks with timing, sentiment, the interaction',
190
+ },
191
+ },
192
+ args: [1, 1],
193
+ async run(ctx) {
194
+ const id = conf(ctx);
195
+ const wda = ctx.client(wdaClient);
196
+ const base = apiPath`/${id}`;
197
+ if (ctx.values.structured) {
198
+ if (ctx.values.to) throw new UsageError('--to writes the dialogue: leave out --structured');
199
+ const [r, rec] = await Promise.all([
200
+ wda.soft(`/history/conferences${base}/transcription`),
201
+ ctx.output === 'text' ? optional(wda.get(`/history/conferences${base}`), ctx.info) : null,
202
+ ]);
203
+ if (!r) throw new NoDataError(NO_TRANSCRIPT);
204
+ if (rec)
205
+ Object.defineProperty(r, 'names', {
206
+ value: Object.fromEntries(
207
+ (rec.conference?.participants ?? []).map((p) => [p.jid, alt(p.info?.name, p.info?.email, p.jid)]),
208
+ ),
209
+ enumerable: false,
210
+ });
211
+ return r;
212
+ }
213
+ const [text, rec] = await Promise.allSettled([
214
+ wda.soft(`/history/conferences${base}/transcription/text`),
215
+ wda.get(`/history/conferences${base}`),
216
+ ]);
217
+ if (text.status === 'rejected') throw text.reason;
218
+ if (!text.value) throw new NoDataError(NO_TRANSCRIPT);
219
+ if (rec.status === 'rejected') throw rec.reason;
220
+ const c = rec.value.conference ?? {};
221
+ const body = text.value;
222
+ const dialogue = (body.text ?? '').trim();
223
+ if (c.transcriptionStatus === 'UNAVAILABLE')
224
+ throw new NoDataError(
225
+ "Transcription is not available for this conference (transcriptionStatus 'UNAVAILABLE'): it was most likely never enabled on the call.",
226
+ );
227
+ if (!dialogue) {
228
+ if (c.transcriptionStatus === 'POST_TRANSCRIPTION')
229
+ throw new NoDataError(
230
+ "The transcription is still being processed (transcriptionStatus 'POST_TRANSCRIPTION'): try again later.",
231
+ );
232
+ throw new NoDataError('The transcription is empty: nothing was transcribed on this call.');
233
+ }
234
+ const lines = (body.chunks ?? []).length;
235
+ const header = transcriptHeader(id, c, lines);
236
+ if (ctx.values.to)
237
+ return {
238
+ file: writePrivate(ctx.values.to, `${header}${dialogue}\n`, {cwd: ctx.cwd, conferenceId: id, env: ctx.env}),
239
+ };
240
+ const out = {
241
+ conference: {
242
+ id,
243
+ subject: c.subject ?? null,
244
+ url: conferenceLink(id),
245
+ start: isoSeconds(c.startTime),
246
+ durationMin: Math.round((c.duration ?? 0) / 60000),
247
+ language: c.transcriptionLanguage ?? null,
248
+ participants: participantNames(c),
249
+ transcriptionStatus: c.transcriptionStatus ?? null,
250
+ },
251
+ ...body,
252
+ };
253
+ Object.defineProperty(out, 'header', {value: header, enumerable: false});
254
+ return out;
255
+ },
256
+ text(r, ctx) {
257
+ if (r.file) return r.file;
258
+ if (ctx.values.structured) {
259
+ return (r.transcription?.transcriptions ?? []).flatMap((t) =>
260
+ structuredLines(t.chunks ?? [], t.start, r.names ?? {}),
261
+ );
262
+ }
263
+ return `${r.header}${(r.text ?? '').trim()}`;
264
+ },
265
+ },
266
+ annotations: {
267
+ summary: 'the annotations of a meeting (404 when there are none)',
268
+ usage: `wx meetings annotations ${CONF}`,
269
+ options: {},
270
+ args: [1, 1],
271
+ run: (ctx) => get(ctx, ['history', '/annotations']),
272
+ text: (r) => annotationLines(r.annotations?.annotations ?? []),
273
+ },
274
+ insights: {
275
+ summary: 'the AI insights that were generated: {projection: {field: value}}',
276
+ usage: `wx meetings insights ${CONF} [--raw]`,
277
+ options: {raw: {type: 'boolean', description: 'every projection as the service gives it, with its status'}},
278
+ args: [1, 1],
279
+ async run(ctx) {
280
+ const r = await get(ctx, ['insights', '/insights']);
281
+ return ctx.values.raw ? r : {status: r.status ?? null, insights: compactInsights(r)};
282
+ },
283
+ text: insightsText,
284
+ },
285
+ summary: {
286
+ summary: 'the AI title, brief and the structured summary of a meeting',
287
+ usage: `wx meetings summary ${CONF}`,
288
+ options: {},
289
+ args: [1, 1],
290
+ run: (ctx) => get(ctx, ['insights', '/summary']),
291
+ text: summaryLines,
292
+ },
293
+ state: {
294
+ summary: 'your own state of a meeting: playlists, playback position',
295
+ usage: `wx meetings state ${CONF}`,
296
+ options: {},
297
+ args: [1, 1],
298
+ run: (ctx) => get(ctx, ['insights', '/state']),
299
+ text: stateText,
300
+ },
301
+ timeframes: {
302
+ summary: 'who spoke when in a meeting',
303
+ usage: `wx meetings timeframes ${CONF}`,
304
+ options: {},
305
+ args: [1, 1],
306
+ async run(ctx) {
307
+ const wda = ctx.client(wdaClient);
308
+ const base = apiPath`/${conf(ctx)}`;
309
+ const [r, rec] = await Promise.all([
310
+ wda.get(`/insights/conferences${base}/timeframes`),
311
+ ctx.output === 'text' ? optional(wda.get(`/history/conferences${base}`), ctx.info) : null,
312
+ ]);
313
+ if (rec) {
314
+ const c = rec.conference ?? {};
315
+ const names = Object.fromEntries(
316
+ (c.participants ?? []).map((p) => [p.jid, alt(p.info?.name, p.info?.email, p.jid)]),
317
+ );
318
+ Object.defineProperty(r, 'names', {value: names, enumerable: false});
319
+ Object.defineProperty(r, 'startTime', {value: c.startTime, enumerable: false});
320
+ }
321
+ return r;
322
+ },
323
+ text(r) {
324
+ const list = r.timeFrames ?? [];
325
+ if (!list.length) return 'No time frames.';
326
+ const t0 = r.startTime ?? Math.min(...list.flatMap((t) => (t.frames ?? []).map((f) => f.start)));
327
+ const at = (ms) => millis(ms - t0);
328
+ return table(
329
+ list.map((t) => {
330
+ const frames = t.frames ?? [];
331
+ const total = frames.reduce((n, f) => n + Math.max(0, (f.end ?? f.start) - f.start), 0);
332
+ const spans = frames
333
+ .slice(0, 12)
334
+ .map((f) => `${at(f.start)}-${at(f.end ?? f.start)}`)
335
+ .join(' ');
336
+ return [
337
+ oneLine(r.names?.[t.jid] ?? t.jid, 40),
338
+ millis(total),
339
+ plural(frames.length, 'time'),
340
+ spans + (frames.length > 12 ? ' …' : ''),
341
+ ];
342
+ }),
343
+ );
344
+ },
345
+ },
346
+ 'recording-url': {
347
+ summary: 'a link to download or play a recording of a meeting',
348
+ usage: 'wx meetings recording-url --file-id ID --category channels|recordings|wizyconfRecordings [--download]',
349
+ details:
350
+ 'The file id is the identifier of the recording file as the record gives it. The link is presigned: whoever has it can open the file until it expires.',
351
+ options: {
352
+ 'file-id': {type: 'string', placeholder: 'ID', description: 'the identifier of the recording file'},
353
+ category: {
354
+ type: 'string',
355
+ choices: ['channels', 'recordings', 'wizyconfRecordings'],
356
+ description: 'the category of the file',
357
+ },
358
+ download: {type: 'boolean', description: 'a link that always downloads (else audio and video play)'},
359
+ },
360
+ args: [0, 0],
361
+ async run(ctx) {
362
+ const v = ctx.values;
363
+ if (!v['file-id'] || !v.category) throw new UsageError('--file-id and --category are required', ctx.usage());
364
+ if (v['file-id'].length > 1024 || /[\s\0]/.test(v['file-id']))
365
+ throw new UsageError(`not a file id: ${v['file-id']}`);
366
+ return ctx
367
+ .client(wdaClient)
368
+ .get(
369
+ `/insights/conferences/recording/download_url${query({fileId: v['file-id'], category: v.category, download: v.download ? '1' : undefined})}`,
370
+ );
371
+ },
372
+ text: (r) => r.presignedDownloadUrl ?? '',
373
+ },
374
+ 'schedule-summary': {
375
+ summary: 'ask the service to generate the summary of a meeting again (only when asked: it does work)',
376
+ usage: `wx meetings schedule-summary ${CONF}`,
377
+ options: {},
378
+ args: [1, 1],
379
+ run: (ctx) =>
380
+ ctx.client(wdaClient).post(`/insights/conferences${apiPath`/${conf(ctx)}`}/schedule_summary_generation`),
381
+ text: () => 'The summary was scheduled: read it later with wx meetings summary.',
382
+ },
383
+ },
384
+ };
385
+
386
+ export const groups = {meetings: group};