@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/README.md +117 -0
- package/bin/cli.js +12 -0
- package/build.js +308 -0
- package/data.js +724 -0
- package/package.json +54 -0
- package/src/d3.v7.min.js +2 -0
- package/src/default-translations.json +76 -0
- package/src/fonts/noto-sans-v42-latin-500.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-500.woff2 +0 -0
- package/src/fonts/noto-sans-v42-latin-600.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-600.woff2 +0 -0
- package/src/fonts/noto-sans-v42-latin-700.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-700.woff2 +0 -0
- package/src/fonts/noto-sans-v42-latin-regular.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-regular.woff2 +0 -0
- package/src/index.mustache +304 -0
- package/src/popper.min.js +6 -0
- package/src/print.css +120 -0
- package/src/reset.css +55 -0
- package/src/script.js +1251 -0
- package/src/style.css +1609 -0
- package/src/svg/check.svg +3 -0
- package/src/svg/copy.svg +10 -0
- package/src/svg/exclamation.svg +3 -0
- package/src/svg/eye.svg +10 -0
- package/src/svg/filter.svg +3 -0
- package/src/svg/group.svg +10 -0
- package/src/svg/hamburger.svg +5 -0
- package/src/svg/list.svg +10 -0
- package/src/svg/list_alt.svg +10 -0
- package/src/svg/predict.svg +10 -0
- package/src/svg/reset.svg +3 -0
- package/src/svg/share.svg +10 -0
- package/src/svg/star.svg +10 -0
- package/src/svg/thumbs.svg +10 -0
- package/src/svg/trash.svg +3 -0
- package/src/svg/vote.svg +10 -0
- package/src/svg/x.svg +3 -0
- package/src/tippy-bundle.umd.min.js +2 -0
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
|
+
}
|