@cosla/sensemaking-report-builder 0.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.
package/data.js ADDED
@@ -0,0 +1,724 @@
1
+ /**
2
+ * @fileoverview Data script for the report.
3
+ * This script performs the ETL (Extract, Transform, Load) process:
4
+ * 1. Loads raw opinion data, configuration, and AI-generated summaries.
5
+ * 2. Transforms flat opinion lists into a hierarchical structure (Topics -> Opinions -> Quotes).
6
+ * 3. Calculates statistics (participant counts, bridging scores).
7
+ * 4. Generates formatted JSON payloads for the frontend ("static" and "inline" variations).
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ const demographics_prefix = "demo:";
14
+
15
+ /**
16
+ * @typedef {Object} RawOpinion
17
+ * @property {string} topic - The high-level topic category.
18
+ * @property {string} opinion - The specific opinion text.
19
+ * @property {string} quote - The actual quote text.
20
+ * @property {string} participant_id - Representative ID (Participant ID).
21
+ * @property {string|number} [AVERAGE_OF_2_BRIDGING] - Used for sorting.
22
+ * @property {string|number} [AVERAGE_OF_3_BRIDGING] - Alternate bridging column.
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} BuildOptions
27
+ * @property {"inline"|"static"} command
28
+ * @property {string} inputDir
29
+ * @property {string|null} output
30
+ * @property {string|null} outputDir
31
+ * @property {string} opinionsPath
32
+ * @property {string} summaryPath
33
+ * @property {string|null} predictedPath
34
+ * @property {string|null} configPath
35
+ * @property {boolean} predictedExplicit
36
+ * @property {boolean} configExplicit
37
+ */
38
+
39
+ /**
40
+ * Parses argv flags into a Map of key -> value.
41
+ * @param {string[]} args
42
+ * @returns {Map<string, string>}
43
+ */
44
+ function parseFlags(args) {
45
+ const flags = new Map();
46
+ for (let index = 0; index < args.length; index += 1) {
47
+ const token = args[index];
48
+ if (!token.startsWith("--")) continue;
49
+ const key = token.slice(2);
50
+ const next = args[index + 1];
51
+ if (next && !next.startsWith("--")) {
52
+ flags.set(key, next);
53
+ index += 1;
54
+ } else {
55
+ flags.set(key, "true");
56
+ }
57
+ }
58
+ return flags;
59
+ }
60
+
61
+ /**
62
+ * Resolves CLI argv into absolute build paths.
63
+ * @param {string[]} [argv=process.argv]
64
+ * @param {string} [cwd=process.cwd()]
65
+ * @returns {BuildOptions}
66
+ */
67
+ export function resolveBuildOptions(argv = process.argv, cwd = process.cwd()) {
68
+ const args = argv.slice(2);
69
+ const commandToken = args[0] && !args[0].startsWith("--") ? args[0] : null;
70
+ if (!commandToken) {
71
+ throw new Error(
72
+ 'Build mode is required. Use "inline" or "static" as the first argument.',
73
+ );
74
+ }
75
+ if (commandToken !== "inline" && commandToken !== "static") {
76
+ throw new Error(
77
+ `Unsupported command "${commandToken}". Use "inline" or "static".`,
78
+ );
79
+ }
80
+
81
+ const flags = parseFlags(args);
82
+ const hasOutput = flags.has("output");
83
+ const hasOutputDir = flags.has("outputDir");
84
+
85
+ if (hasOutput && hasOutputDir) {
86
+ throw new Error(
87
+ "Use either --output (inline) or --outputDir (static), not both.",
88
+ );
89
+ }
90
+
91
+ let output = null;
92
+ let outputDir = null;
93
+
94
+ if (commandToken === "inline") {
95
+ if (hasOutputDir) {
96
+ throw new Error(
97
+ "inline mode writes a single HTML file; use --output <file.html> (not --outputDir).",
98
+ );
99
+ }
100
+ output = path.resolve(cwd, flags.get("output") || "output/report.html");
101
+ } else {
102
+ if (hasOutput) {
103
+ throw new Error(
104
+ "static mode writes multiple artefacts; use --outputDir <dir> (not --output).",
105
+ );
106
+ }
107
+ outputDir = path.resolve(cwd, flags.get("outputDir") || "output");
108
+ }
109
+
110
+ const inputDir = path.resolve(cwd, flags.get("inputDir") || "input");
111
+
112
+ const opinionsFlag = flags.get("opinions");
113
+ const bridgingFlag = flags.get("bridging_scores");
114
+ if (opinionsFlag && bridgingFlag) {
115
+ const opinionsResolved = path.resolve(cwd, opinionsFlag);
116
+ const bridgingResolved = path.resolve(cwd, bridgingFlag);
117
+ if (opinionsResolved !== bridgingResolved) {
118
+ throw new Error(
119
+ "--opinions and --bridging_scores both set to different paths; use only one.",
120
+ );
121
+ }
122
+ }
123
+ const opinionsPath = path.resolve(
124
+ cwd,
125
+ opinionsFlag ||
126
+ bridgingFlag ||
127
+ path.join(inputDir, "opinions.csv"),
128
+ );
129
+ const summaryPath = path.resolve(
130
+ cwd,
131
+ flags.get("summary") || path.join(inputDir, "summary.json"),
132
+ );
133
+
134
+ const predictedExplicit = flags.has("predicted");
135
+ const configExplicit = flags.has("config");
136
+
137
+ let predictedPath = null;
138
+ if (predictedExplicit) {
139
+ predictedPath = path.resolve(cwd, flags.get("predicted"));
140
+ } else {
141
+ const defaultPredicted = path.join(inputDir, "predicted.json");
142
+ if (fs.existsSync(defaultPredicted)) {
143
+ predictedPath = defaultPredicted;
144
+ }
145
+ }
146
+
147
+ let configPath = null;
148
+ if (configExplicit) {
149
+ configPath = path.resolve(cwd, flags.get("config"));
150
+ } else {
151
+ const defaultConfig = path.join(inputDir, "config.json");
152
+ if (fs.existsSync(defaultConfig)) {
153
+ configPath = defaultConfig;
154
+ }
155
+ }
156
+
157
+ if (!fs.existsSync(opinionsPath)) {
158
+ throw new Error(`Opinions CSV not found: ${opinionsPath}`);
159
+ }
160
+ if (!fs.existsSync(summaryPath)) {
161
+ throw new Error(`Summary JSON not found: ${summaryPath}`);
162
+ }
163
+ if (predictedExplicit && !fs.existsSync(predictedPath)) {
164
+ throw new Error(`Predicted JSON not found: ${predictedPath}`);
165
+ }
166
+ if (configExplicit && !fs.existsSync(configPath)) {
167
+ throw new Error(`Config JSON not found: ${configPath}`);
168
+ }
169
+
170
+ return {
171
+ command: commandToken,
172
+ inputDir,
173
+ output,
174
+ outputDir,
175
+ opinionsPath,
176
+ summaryPath,
177
+ predictedPath,
178
+ configPath,
179
+ predictedExplicit,
180
+ configExplicit,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Recursively deep merges source object into target object.
186
+ * @param {Object} target
187
+ * @param {Object} source
188
+ * @returns {Object}
189
+ */
190
+ function deepMerge(target, source) {
191
+ const output = { ...target };
192
+ for (const key of Object.keys(source || {})) {
193
+ if (
194
+ source[key] instanceof Object &&
195
+ key in target &&
196
+ target[key] instanceof Object &&
197
+ !Array.isArray(source[key])
198
+ ) {
199
+ output[key] = deepMerge(target[key], source[key]);
200
+ } else if (source[key] !== undefined) {
201
+ output[key] = source[key];
202
+ }
203
+ }
204
+ return output;
205
+ }
206
+
207
+ /**
208
+ * Converts a string of markdown to clean HTML.
209
+ * @param {string} text
210
+ * @returns {string}
211
+ */
212
+ function cleanMarkdown(text) {
213
+ if (!text) return "";
214
+ let html = text;
215
+ html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
216
+ html = html.replace(/\*(.*?)\*/g, "<em>$1</em>");
217
+ return html;
218
+ }
219
+
220
+ /**
221
+ * Strips markdown header symbols (e.g. '#', '##') and leading whitespace.
222
+ * @param {string} text
223
+ * @returns {string}
224
+ */
225
+ function stripMarkdownHeader(text) {
226
+ if (!text) return "";
227
+ return text.replace(/^#+\s*/, "");
228
+ }
229
+
230
+ /**
231
+ * Sums an array of numbers.
232
+ * @param {number[]} arr
233
+ * @returns {number}
234
+ */
235
+ function sum(arr) {
236
+ return arr.reduce((a, b) => a + b, 0);
237
+ }
238
+
239
+ /**
240
+ * Groups an array of objects by a specific property key.
241
+ * @param {Object[]} array
242
+ * @param {string} column
243
+ * @returns {[string, Object[]][]}
244
+ */
245
+ function groupBy(array, column) {
246
+ const map = new Map();
247
+ array.forEach((item) => {
248
+ const key = item[column];
249
+ if (!map.has(key)) map.set(key, []);
250
+ map.get(key).push(item);
251
+ });
252
+ return Array.from(map);
253
+ }
254
+
255
+ /**
256
+ * Generates a URL-safe slug from a string supporting all Unicode scripts.
257
+ * @param {string} str
258
+ * @param {boolean} [useFirstWords=false]
259
+ * @returns {string}
260
+ */
261
+ function generateId(str, useFirstWords = false) {
262
+ if (!str) return "item";
263
+ const words = str
264
+ .split(" ")
265
+ .slice(0, useFirstWords ? 5 : undefined)
266
+ .join(" ");
267
+ return (
268
+ words
269
+ .toLowerCase()
270
+ .normalize("NFD")
271
+ .replace(/[\u0300-\u036f]/g, "")
272
+ .replace(/[^\p{L}\p{N}]+/gu, "") || "item"
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Reads bridging score from AVERAGE_OF_2_BRIDGING or AVERAGE_OF_3_BRIDGING.
278
+ * @param {RawOpinion} row
279
+ * @returns {number}
280
+ */
281
+ function bridgingScore(row) {
282
+ const raw = row.AVERAGE_OF_2_BRIDGING ?? row.AVERAGE_OF_3_BRIDGING;
283
+ return raw ? +raw : 0;
284
+ }
285
+
286
+ /**
287
+ * Resolves optional user i18n file under the staged input directory.
288
+ * @param {Object} config
289
+ * @param {string} inputDir
290
+ * @returns {string|null}
291
+ */
292
+ function resolveUserI18nPath(config, inputDir) {
293
+ if (config.translations) {
294
+ const direct = path.join(inputDir, config.translations);
295
+ if (fs.existsSync(direct)) return direct;
296
+ const withJson = path.join(inputDir, `${config.translations}.json`);
297
+ if (fs.existsSync(withJson)) return withJson;
298
+ }
299
+ const defaultPath = path.join(inputDir, "translations.json");
300
+ if (fs.existsSync(defaultPath)) return defaultPath;
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Processes staged report inputs into static/inline Mustache payloads.
306
+ * @param {Object} params
307
+ * @param {RawOpinion[]} params.opinionsRaw
308
+ * @param {string} params.summaryPath
309
+ * @param {string|null} params.configPath
310
+ * @param {string|null} params.predictedPath
311
+ * @param {string} params.inputDir - Staged work dir (for translations / relative paths)
312
+ * @param {string} params.packageRoot
313
+ * @param {string} params.workDir - Directory to write quotes + data JSON files
314
+ * @returns {{ dataStatic: Object, dataInline: Object, quotes: Object[] }}
315
+ */
316
+ export function processReportData({
317
+ opinionsRaw,
318
+ summaryPath,
319
+ configPath,
320
+ predictedPath,
321
+ inputDir,
322
+ packageRoot,
323
+ workDir,
324
+ }) {
325
+ const opinions = opinionsRaw.map((d, index) => ({
326
+ ...d,
327
+ index,
328
+ }));
329
+
330
+ const config = configPath
331
+ ? JSON.parse(fs.readFileSync(configPath, "utf-8"))
332
+ : {};
333
+
334
+ const summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
335
+
336
+ const predictedRaw = predictedPath
337
+ ? JSON.parse(fs.readFileSync(predictedPath, "utf-8"))
338
+ : [];
339
+
340
+ const defaultI18n = JSON.parse(
341
+ fs.readFileSync(
342
+ path.join(packageRoot, "src", "default-translations.json"),
343
+ "utf-8",
344
+ ),
345
+ );
346
+
347
+ const userI18nPath = resolveUserI18nPath(config, inputDir);
348
+ const userI18n = userI18nPath
349
+ ? JSON.parse(fs.readFileSync(userI18nPath, "utf-8"))
350
+ : {};
351
+
352
+ const i18n = deepMerge(defaultI18n, userI18n);
353
+ i18n.locale = i18n.locale || "en";
354
+ i18n.direction = i18n.direction || "ltr";
355
+
356
+ const numberFormatter = new Intl.NumberFormat(i18n.locale);
357
+ const percentFormatter = new Intl.NumberFormat(i18n.locale, {
358
+ style: "percent",
359
+ maximumFractionDigits: 1,
360
+ });
361
+
362
+ /**
363
+ * @param {number|string} value
364
+ * @returns {string|null}
365
+ */
366
+ function formatPercent(value) {
367
+ if (value == null || value === "") return null;
368
+ const num = Number(value);
369
+ if (isNaN(num)) return null;
370
+ const ratio = num > 1 ? num / 100 : num;
371
+ return percentFormatter.format(ratio);
372
+ }
373
+
374
+ /**
375
+ * @param {number} num
376
+ * @returns {string}
377
+ */
378
+ function formatNumber(num) {
379
+ return numberFormatter.format(num);
380
+ }
381
+
382
+ const overviewChart = config.overview_chart || "toggle";
383
+ const reportOptions = {
384
+ logo: config.logo || "",
385
+ overviewChart,
386
+ hasToggle: overviewChart === "toggle",
387
+ lowSampleThreshold: config.low_sample_warning_threshold || 30,
388
+ sampleQuoteCount: Math.min(
389
+ Math.max(config.number_of_sample_quotes || 4, 2),
390
+ 10,
391
+ ),
392
+ topOpinionCount: Math.min(
393
+ Math.max(config.number_of_top_opinions || 10, 2),
394
+ 20,
395
+ ),
396
+ topicColors: config.chart_colors || [
397
+ "#AFB42B",
398
+ "#F4511E",
399
+ "#3949AB",
400
+ "#E52592",
401
+ "#00897B",
402
+ "#EFB22F",
403
+ "#aaa",
404
+ ],
405
+ demographicColors: config.demographic_colors || [
406
+ "#4886f7",
407
+ "#4071d5",
408
+ "#385db3",
409
+ "#2f4a93",
410
+ "#273874",
411
+ "#1e2656",
412
+ ],
413
+ };
414
+
415
+ /**
416
+ * @param {RawOpinion[]} values
417
+ * @returns {Object[]}
418
+ */
419
+ function sortAndExtractQuotes(values) {
420
+ return values
421
+ .map((v) => {
422
+ const demos = Object.fromEntries(
423
+ Object.entries(v)
424
+ .filter(([k]) => k.startsWith(demographics_prefix))
425
+ .map(([k, val]) => [k.slice(demographics_prefix.length), val]),
426
+ );
427
+ return {
428
+ index: v.index,
429
+ text: v.quote,
430
+ participant_id: v.participant_id,
431
+ avg_bridging: bridgingScore(v),
432
+ ...demos,
433
+ };
434
+ })
435
+ .sort((a, b) => b.avg_bridging - a.avg_bridging)
436
+ .filter((v) => v.text);
437
+ }
438
+
439
+ /**
440
+ * @param {RawOpinion[]} opinionsList
441
+ * @returns {Object[]}
442
+ */
443
+ function groupOpinions(opinionsList) {
444
+ const byTopic = groupBy(opinionsList, "topic");
445
+ return byTopic.map(([topicText, topicOpinions]) => {
446
+ const topicMatch = (summary.sub_contents || []).find(
447
+ (t) => stripMarkdownHeader(t.title) === topicText,
448
+ );
449
+ const topicId = generateId(topicText, true);
450
+ const byOpinion = groupBy(topicOpinions, "opinion").map(([_, values]) => ({
451
+ opinionID: generateId(values[0].opinion),
452
+ fullID: `${topicId}-${generateId(values[0].opinion)}`,
453
+ text: values[0].opinion,
454
+ count: values.length,
455
+ quotes: sortAndExtractQuotes(values),
456
+ }));
457
+
458
+ byOpinion.sort((a, b) => {
459
+ if (a.text === "Other") return 1;
460
+ if (b.text === "Other") return -1;
461
+ return b.count - a.count;
462
+ });
463
+
464
+ return {
465
+ topicID: topicId,
466
+ summary: cleanMarkdown(topicMatch?.text),
467
+ text: topicText,
468
+ count: topicOpinions.length,
469
+ opinions: byOpinion,
470
+ };
471
+ });
472
+ }
473
+
474
+ /**
475
+ * @param {Object[]} opinionsGrouped
476
+ * @returns {Object[]}
477
+ */
478
+ function flattenQuotes(opinionsGrouped) {
479
+ const flat = [];
480
+ opinionsGrouped.forEach((topic) => {
481
+ topic.opinions.forEach((opinion) => {
482
+ opinion.quotes.forEach((quote) => {
483
+ const { index, text, participant_id, avg_bridging, fullID, ...demos } =
484
+ quote;
485
+ flat.push({
486
+ id: opinion.fullID,
487
+ quote: quote.text,
488
+ ...demos,
489
+ });
490
+ });
491
+ });
492
+ });
493
+ return flat;
494
+ }
495
+
496
+ /**
497
+ * @param {string} text
498
+ * @returns {string[]}
499
+ */
500
+ function parseSummary(text) {
501
+ return text.split("\n\n").map((p) => p.trim());
502
+ }
503
+
504
+ const globalSampleParticipants = new Set();
505
+
506
+ /**
507
+ * @param {Object[]} topicOpinions
508
+ * @returns {Object[]}
509
+ */
510
+ function getSampleQuotes(topicOpinions) {
511
+ const allQuotes = topicOpinions
512
+ .map((o) => o.quotes.map((q) => ({ ...q, fullID: o.fullID })))
513
+ .flat();
514
+ allQuotes.sort((a, b) => b.avg_bridging - a.avg_bridging);
515
+
516
+ const selected = [];
517
+ for (let i = 0; i < reportOptions.sampleQuoteCount; i++) {
518
+ for (const o of topicOpinions) {
519
+ const possible = allQuotes.filter((q) => q.fullID === o.fullID);
520
+ if (!possible.length) continue;
521
+ let newQuote = possible.find(
522
+ (q) =>
523
+ !globalSampleParticipants.has(q.participant_id) &&
524
+ !selected.find((s) => s.participant_id === q.participant_id),
525
+ );
526
+ if (!newQuote) {
527
+ newQuote = possible.find(
528
+ (q) => !selected.find((s) => s.participant_id === q.participant_id),
529
+ );
530
+ }
531
+ if (!newQuote) {
532
+ newQuote = possible.find(
533
+ (q) => !selected.find((s) => s.index === q.index),
534
+ );
535
+ }
536
+ if (newQuote) {
537
+ selected.push({ ...newQuote });
538
+ globalSampleParticipants.add(newQuote.participant_id);
539
+ }
540
+ }
541
+ }
542
+ return selected;
543
+ }
544
+
545
+ /**
546
+ * @param {Object[]} topicOpinions
547
+ * @returns {number}
548
+ */
549
+ function getUniqueQuoteCount(topicOpinions) {
550
+ const uniqueParticipantIds = new Set();
551
+ topicOpinions.forEach((o) => {
552
+ o.quotes.forEach((q) => {
553
+ uniqueParticipantIds.add(q.participant_id);
554
+ });
555
+ });
556
+ return uniqueParticipantIds.size;
557
+ }
558
+
559
+ /**
560
+ * @param {Object} raw
561
+ * @returns {{text: string, topics: Object[]}}
562
+ */
563
+ function processPredicted(raw) {
564
+ const data = Array.isArray(raw) ? raw[0] : raw;
565
+ if (!data || !data.sub_contents) return { text: "", topics: [] };
566
+ return {
567
+ text: data.text,
568
+ topics: data.sub_contents.map((s) => ({
569
+ topicID: generateId(s.title || "", true),
570
+ title: stripMarkdownHeader(s.title),
571
+ text: s.text,
572
+ statements: (s.statements || []).map((stmt) => ({
573
+ text: stmt.text,
574
+ predictedAgreement: formatPercent(stmt.predicted_agreement),
575
+ hasPredictedAgreement: stmt.predicted_agreement != null,
576
+ })),
577
+ })),
578
+ };
579
+ }
580
+
581
+ const byParticipant = groupBy(opinions, "participant_id");
582
+ const totalParticipants = formatNumber(byParticipant.length);
583
+ const totalParticipantsFormatted = formatNumber(byParticipant.length);
584
+ const propositionsGenerated = 0;
585
+
586
+ const opinionsGrouped = groupOpinions(opinions);
587
+ const quotes = flattenQuotes(opinionsGrouped);
588
+ const predicted = processPredicted(predictedRaw);
589
+
590
+ const topicsIdentified = (summary.sub_contents || []).length;
591
+ const topicsIdentifiedFormatted = formatNumber(topicsIdentified);
592
+ const opinionsIdentified = opinionsGrouped
593
+ .map((t) => t.opinions.length)
594
+ .reduce((a, b) => a + b, 0);
595
+ const opinionsIdentifiedFormatted = formatNumber(opinionsIdentified);
596
+
597
+ const topics = opinionsGrouped.map((topic) => {
598
+ const allSampleQuotes = getSampleQuotes(topic.opinions);
599
+ return {
600
+ topicID: topic.topicID,
601
+ text: topic.text,
602
+ topicCount: topic.count,
603
+ topicCountFormatted: formatNumber(topic.count),
604
+ opinionCount: topic.opinions.length,
605
+ opinionCountFormatted: formatNumber(topic.opinions.length),
606
+ rawQuoteCount: sum(topic.opinions.map((o) => o.count)),
607
+ quoteCount: getUniqueQuoteCount(topic.opinions),
608
+ quoteCountFormatted: formatNumber(getUniqueQuoteCount(topic.opinions)),
609
+ summary: topic.summary,
610
+ opinions: topic.opinions.map((o) => ({
611
+ text: o.text,
612
+ count: o.count,
613
+ countFormatted: formatNumber(o.count),
614
+ quotesCountFormatted: (
615
+ i18n.sections?.quotesCount || "{count} Quotes"
616
+ ).replace("{count}", formatNumber(o.count)),
617
+ sampleQuotes: allSampleQuotes
618
+ .filter((q) => q.fullID === o.fullID)
619
+ .map((q) => q.text),
620
+ viewAllQuotes: o.quotes.length > reportOptions.sampleQuoteCount,
621
+ fullID: o.fullID,
622
+ })),
623
+ };
624
+ });
625
+
626
+ topics.sort((a, b) => b.quoteCount - a.quoteCount);
627
+
628
+ const uniqueParticipants = byParticipant.map(([, rows]) => rows[0]);
629
+ const demoKeys = Object.keys(uniqueParticipants[0] || {}).filter((k) =>
630
+ k.startsWith(demographics_prefix),
631
+ );
632
+
633
+ const demographics = demoKeys.map((key) => {
634
+ const label = key.slice(demographics_prefix.length);
635
+ const counts = new Map();
636
+ uniqueParticipants.forEach((p) => {
637
+ const val = p[key];
638
+ if (val !== undefined && val !== null && val !== "") {
639
+ counts.set(val, (counts.get(val) || 0) + 1);
640
+ }
641
+ });
642
+ let values = Array.from(counts, ([value, count]) => ({
643
+ value,
644
+ count,
645
+ })).sort((a, b) => b.count - a.count);
646
+
647
+ if (values.length > 6) {
648
+ const otherCount = values.slice(5).reduce((acc, v) => acc + v.count, 0);
649
+ const otherCategory = i18n.chart?.otherCategory || "Other";
650
+ values = [
651
+ ...values.slice(0, 5),
652
+ { value: otherCategory, count: otherCount },
653
+ ];
654
+ }
655
+
656
+ return { label, values };
657
+ });
658
+
659
+ demographics.sort((a, b) => a.label.localeCompare(b.label, i18n.locale));
660
+
661
+ const executiveSummary = parseSummary(cleanMarkdown(summary.text || ""));
662
+ const title = stripMarkdownHeader(summary.title);
663
+
664
+ const conversationOverviewLead = (
665
+ i18n.sections?.conversationLeadTemplate ||
666
+ "Below is a high level overview of the topics discussed in the conversation. The most discussed topics were {topTopic1} and {topTopic2}."
667
+ )
668
+ .replace("{topTopic1}", topics[0]?.text || "")
669
+ .replace("{topTopic2}", topics[1]?.text || "");
670
+
671
+ const topicsIdentifiedBadge = (
672
+ i18n.sections?.topicsIdentifiedBadge || "{count} topics identified"
673
+ ).replace("{count}", topicsIdentifiedFormatted);
674
+
675
+ const baseOutput = {
676
+ ...reportOptions,
677
+ title,
678
+ executiveSummary,
679
+ conversationOverviewLead,
680
+ totalParticipants,
681
+ totalParticipantsFormatted,
682
+ topicsIdentified,
683
+ topicsIdentifiedFormatted,
684
+ topicsIdentifiedBadge,
685
+ opinionsIdentified,
686
+ opinionsIdentifiedFormatted,
687
+ propositionsGenerated,
688
+ topics,
689
+ demographics,
690
+ predicted,
691
+ hasPredicted: predicted.topics.length > 0,
692
+ i18n,
693
+ };
694
+
695
+ fs.writeFileSync(path.join(workDir, "quotes.json"), JSON.stringify(quotes));
696
+
697
+ const dataStatic = { ...baseOutput };
698
+ dataStatic.payload = JSON.stringify({
699
+ topics,
700
+ demographics,
701
+ options: reportOptions,
702
+ i18n,
703
+ }).replace(/</g, "\\u003c");
704
+ fs.writeFileSync(
705
+ path.join(workDir, "data-static.json"),
706
+ JSON.stringify(dataStatic),
707
+ );
708
+
709
+ const dataInline = { ...baseOutput };
710
+ dataInline.payload = JSON.stringify({
711
+ topics,
712
+ demographics,
713
+ options: reportOptions,
714
+ quotes,
715
+ i18n,
716
+ }).replace(/</g, "\\u003c");
717
+ fs.writeFileSync(
718
+ path.join(workDir, "data-inline.json"),
719
+ JSON.stringify(dataInline),
720
+ );
721
+
722
+ console.log("Data processing complete.");
723
+ return { dataStatic, dataInline, quotes };
724
+ }