@cosla/sensemaking-report-builder 0.1.0 → 0.1.2
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 +17 -2
- package/bin/cli.js +1 -0
- package/build.js +13 -1
- package/data.js +95 -42
- package/package.json +7 -3
- package/schemas/config.v1.json +33 -0
- package/smoke.js +62 -0
package/README.md
CHANGED
|
@@ -55,7 +55,9 @@ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`,
|
|
|
55
55
|
|
|
56
56
|
| Arg / flag | Purpose | Default |
|
|
57
57
|
|------------|---------|---------|
|
|
58
|
-
| `inline` \| `static` | Build mode (**required**) | — |
|
|
58
|
+
| `inline` \| `static` | Build mode (**required** for builds) | — |
|
|
59
|
+
| `config-schema` | Print config JSON Schema to stdout (no inputs) | — |
|
|
60
|
+
| `--schema-version` | With `config-schema`: schema version to print | latest (`1`) |
|
|
59
61
|
| `--inputDir` | Base dir for default input paths | `./input` |
|
|
60
62
|
| `--opinions` / `--bridging_scores` | Opinions / bridging scores CSV | `<inputDir>/opinions.csv` |
|
|
61
63
|
| `--summary` | Summary JSON (`report_data.json`) | `<inputDir>/summary.json` |
|
|
@@ -64,6 +66,19 @@ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`,
|
|
|
64
66
|
| `--output` | **inline only:** path to HTML file | `./output/report.html` |
|
|
65
67
|
| `--outputDir` | **static only:** output directory | `./output` |
|
|
66
68
|
|
|
69
|
+
### Config schema
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx @cosla/sensemaking-report-builder config-schema
|
|
73
|
+
npx @cosla/sensemaking-report-builder config-schema --schema-version 1
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Prints a [JSON Schema draft 2020-12](https://json-schema.org/draft/2020-12/schema) document for supported `config.json` keys (`schemas/config.v1.json`), including application field `config_schema_version`. Unknown `--schema-version` values fail with a clear error.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm run smoke # validates config-schema output
|
|
80
|
+
```
|
|
81
|
+
|
|
67
82
|
Logo and translations are configured in `config.json` (not CLI flags). Place `logo.svg` / `logo.png` next to that config (under `--inputDir` when using dir mode).
|
|
68
83
|
|
|
69
84
|
## Input contracts
|
|
@@ -90,7 +105,7 @@ Same shape as Python `sensemaking-report-text` / `report_data.json`: `text`, `su
|
|
|
90
105
|
| `low_sample_warning_threshold` | Low-sample warning threshold |
|
|
91
106
|
| `topic_colors` / `chart_colors` | Overview chart colours |
|
|
92
107
|
| `demographic_colors` | Participant chart colours |
|
|
93
|
-
| `
|
|
108
|
+
| `excluded_topics` / `excluded_opinions` | Hide named topics/opinions (exact match to CSV columns) |
|
|
94
109
|
|
|
95
110
|
### Predicted agreement (optional)
|
|
96
111
|
|
package/bin/cli.js
CHANGED
package/build.js
CHANGED
|
@@ -10,7 +10,11 @@ import { execSync } from "node:child_process";
|
|
|
10
10
|
import csv from "csvtojson";
|
|
11
11
|
import mustache from "mustache";
|
|
12
12
|
import { inlineSource } from "inline-source";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
loadConfigSchema,
|
|
15
|
+
processReportData,
|
|
16
|
+
resolveBuildOptions,
|
|
17
|
+
} from "./data.js";
|
|
14
18
|
|
|
15
19
|
const WORK_DIR_NAME = ".py-report-ui-work";
|
|
16
20
|
|
|
@@ -173,6 +177,13 @@ function inlineFontDataUris(html, fontsDir) {
|
|
|
173
177
|
export async function runBuild(argv = process.argv, cwd = process.cwd()) {
|
|
174
178
|
const options = resolveBuildOptions(argv, cwd);
|
|
175
179
|
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
180
|
+
|
|
181
|
+
if (options.command === "config-schema") {
|
|
182
|
+
const schema = loadConfigSchema(options.schemaVersion, packageRoot);
|
|
183
|
+
process.stdout.write(`${JSON.stringify(schema, null, 2)}\n`);
|
|
184
|
+
return { command: "config-schema" };
|
|
185
|
+
}
|
|
186
|
+
|
|
176
187
|
const artefactRoot =
|
|
177
188
|
options.command === "inline"
|
|
178
189
|
? path.dirname(options.output)
|
|
@@ -296,6 +307,7 @@ if (isMain) {
|
|
|
296
307
|
} else {
|
|
297
308
|
runBuild(process.argv, process.cwd())
|
|
298
309
|
.then((result) => {
|
|
310
|
+
if (result.command === "config-schema") return;
|
|
299
311
|
console.log(
|
|
300
312
|
`Standalone report generated: ${result.output || result.outputDir}`,
|
|
301
313
|
);
|
package/data.js
CHANGED
|
@@ -24,18 +24,50 @@ const demographics_prefix = "demo:";
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* @typedef {Object} BuildOptions
|
|
27
|
-
* @property {"inline"|"static"} command
|
|
28
|
-
* @property {string}
|
|
29
|
-
* @property {string
|
|
30
|
-
* @property {string|null}
|
|
31
|
-
* @property {string}
|
|
32
|
-
* @property {string}
|
|
33
|
-
* @property {string
|
|
34
|
-
* @property {string|null}
|
|
35
|
-
* @property {
|
|
36
|
-
* @property {boolean}
|
|
27
|
+
* @property {"inline"|"static"|"config-schema"} command
|
|
28
|
+
* @property {string|null} [schemaVersion]
|
|
29
|
+
* @property {string} [inputDir]
|
|
30
|
+
* @property {string|null} [output]
|
|
31
|
+
* @property {string|null} [outputDir]
|
|
32
|
+
* @property {string} [opinionsPath]
|
|
33
|
+
* @property {string} [summaryPath]
|
|
34
|
+
* @property {string|null} [predictedPath]
|
|
35
|
+
* @property {string|null} [configPath]
|
|
36
|
+
* @property {boolean} [predictedExplicit]
|
|
37
|
+
* @property {boolean} [configExplicit]
|
|
37
38
|
*/
|
|
38
39
|
|
|
40
|
+
/** Latest shipped config schema version (omit `--schema-version` → this). */
|
|
41
|
+
export const LATEST_CONFIG_SCHEMA_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
const CONFIG_SCHEMA_FILES = {
|
|
44
|
+
1: "config.v1.json",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Loads a config JSON Schema document for the given version.
|
|
49
|
+
* @param {string|number|null|undefined} schemaVersion
|
|
50
|
+
* @param {string} packageRoot Absolute path to the package root (directory of build.js).
|
|
51
|
+
* @returns {object}
|
|
52
|
+
*/
|
|
53
|
+
export function loadConfigSchema(schemaVersion, packageRoot) {
|
|
54
|
+
const version =
|
|
55
|
+
schemaVersion === undefined || schemaVersion === null || schemaVersion === ""
|
|
56
|
+
? LATEST_CONFIG_SCHEMA_VERSION
|
|
57
|
+
: Number(schemaVersion);
|
|
58
|
+
if (!Number.isInteger(version) || !CONFIG_SCHEMA_FILES[version]) {
|
|
59
|
+
const supported = Object.keys(CONFIG_SCHEMA_FILES).join(", ");
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Unknown config schema version "${schemaVersion}". Supported: ${supported}.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const schemaPath = path.join(packageRoot, "schemas", CONFIG_SCHEMA_FILES[version]);
|
|
65
|
+
if (!fs.existsSync(schemaPath)) {
|
|
66
|
+
throw new Error(`Config schema file not found: ${schemaPath}`);
|
|
67
|
+
}
|
|
68
|
+
return JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
/**
|
|
40
72
|
* Parses argv flags into a Map of key -> value.
|
|
41
73
|
* @param {string[]} args
|
|
@@ -69,16 +101,26 @@ export function resolveBuildOptions(argv = process.argv, cwd = process.cwd()) {
|
|
|
69
101
|
const commandToken = args[0] && !args[0].startsWith("--") ? args[0] : null;
|
|
70
102
|
if (!commandToken) {
|
|
71
103
|
throw new Error(
|
|
72
|
-
'
|
|
104
|
+
'Command is required. Use "inline", "static", or "config-schema" as the first argument.',
|
|
73
105
|
);
|
|
74
106
|
}
|
|
107
|
+
|
|
108
|
+
const flags = parseFlags(args);
|
|
109
|
+
|
|
110
|
+
if (commandToken === "config-schema") {
|
|
111
|
+
return {
|
|
112
|
+
command: commandToken,
|
|
113
|
+
schemaVersion: flags.has("schema-version")
|
|
114
|
+
? flags.get("schema-version")
|
|
115
|
+
: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
75
119
|
if (commandToken !== "inline" && commandToken !== "static") {
|
|
76
120
|
throw new Error(
|
|
77
|
-
`Unsupported command "${commandToken}". Use "inline" or "
|
|
121
|
+
`Unsupported command "${commandToken}". Use "inline", "static", or "config-schema".`,
|
|
78
122
|
);
|
|
79
123
|
}
|
|
80
|
-
|
|
81
|
-
const flags = parseFlags(args);
|
|
82
124
|
const hasOutput = flags.has("output");
|
|
83
125
|
const hasOutputDir = flags.has("outputDir");
|
|
84
126
|
|
|
@@ -380,6 +422,8 @@ export function processReportData({
|
|
|
380
422
|
}
|
|
381
423
|
|
|
382
424
|
const overviewChart = config.overview_chart || "toggle";
|
|
425
|
+
const excludedTopics = new Set(config.excluded_topics || []);
|
|
426
|
+
const excludedOpinions = new Set(config.excluded_opinions || []);
|
|
383
427
|
const reportOptions = {
|
|
384
428
|
logo: config.logo || "",
|
|
385
429
|
overviewChart,
|
|
@@ -441,34 +485,43 @@ export function processReportData({
|
|
|
441
485
|
* @returns {Object[]}
|
|
442
486
|
*/
|
|
443
487
|
function groupOpinions(opinionsList) {
|
|
444
|
-
const byTopic = groupBy(opinionsList, "topic")
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
488
|
+
const byTopic = groupBy(opinionsList, "topic").filter(
|
|
489
|
+
([topicText]) => !excludedTopics.has(topicText),
|
|
490
|
+
);
|
|
491
|
+
return byTopic
|
|
492
|
+
.map(([topicText, topicOpinions]) => {
|
|
493
|
+
const topicMatch = (summary.sub_contents || []).find(
|
|
494
|
+
(t) => stripMarkdownHeader(t.title) === topicText,
|
|
495
|
+
);
|
|
496
|
+
const topicId = generateId(topicText, true);
|
|
497
|
+
const keptOpinions = topicOpinions.filter(
|
|
498
|
+
(row) => !excludedOpinions.has(row.opinion),
|
|
499
|
+
);
|
|
500
|
+
const byOpinion = groupBy(keptOpinions, "opinion").map(
|
|
501
|
+
([_, values]) => ({
|
|
502
|
+
opinionID: generateId(values[0].opinion),
|
|
503
|
+
fullID: `${topicId}-${generateId(values[0].opinion)}`,
|
|
504
|
+
text: values[0].opinion,
|
|
505
|
+
count: values.length,
|
|
506
|
+
quotes: sortAndExtractQuotes(values),
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
463
509
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
510
|
+
byOpinion.sort((a, b) => {
|
|
511
|
+
if (a.text === "Other") return 1;
|
|
512
|
+
if (b.text === "Other") return -1;
|
|
513
|
+
return b.count - a.count;
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
topicID: topicId,
|
|
518
|
+
summary: cleanMarkdown(topicMatch?.text),
|
|
519
|
+
text: topicText,
|
|
520
|
+
count: keptOpinions.length,
|
|
521
|
+
opinions: byOpinion,
|
|
522
|
+
};
|
|
523
|
+
})
|
|
524
|
+
.filter((topic) => topic.opinions.length > 0);
|
|
472
525
|
}
|
|
473
526
|
|
|
474
527
|
/**
|
|
@@ -587,7 +640,7 @@ export function processReportData({
|
|
|
587
640
|
const quotes = flattenQuotes(opinionsGrouped);
|
|
588
641
|
const predicted = processPredicted(predictedRaw);
|
|
589
642
|
|
|
590
|
-
const topicsIdentified =
|
|
643
|
+
const topicsIdentified = opinionsGrouped.length;
|
|
591
644
|
const topicsIdentifiedFormatted = formatNumber(topicsIdentified);
|
|
592
645
|
const opinionsIdentified = opinionsGrouped
|
|
593
646
|
.map((t) => t.opinions.length)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosla/sensemaking-report-builder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Interactive HTML report builder for Cosla Python sensemaking pipeline outputs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build.js",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"build.js",
|
|
13
13
|
"data.js",
|
|
14
14
|
"src/**/*",
|
|
15
|
-
"
|
|
15
|
+
"schemas/**/*",
|
|
16
|
+
"README.md",
|
|
17
|
+
"smoke.js"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"predev": "node bin/cli.js static --outputDir ./output",
|
|
@@ -21,7 +23,9 @@
|
|
|
21
23
|
"inline": "node bin/cli.js inline",
|
|
22
24
|
"build": "node bin/cli.js inline",
|
|
23
25
|
"preview": "node build.js preview",
|
|
24
|
-
"github": "node build.js github"
|
|
26
|
+
"github": "node build.js github",
|
|
27
|
+
"smoke": "node smoke.js",
|
|
28
|
+
"test": "npm run smoke"
|
|
25
29
|
},
|
|
26
30
|
"repository": {
|
|
27
31
|
"type": "git",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"config_schema_version": 1,
|
|
4
|
+
"title": "PythonReportConfig",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": true,
|
|
7
|
+
"properties": {
|
|
8
|
+
"title": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"title": "Report Title Override"
|
|
11
|
+
},
|
|
12
|
+
"logo": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"title": "Logo Image Filename"
|
|
15
|
+
},
|
|
16
|
+
"excluded_topics": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"title": "Excluded Topics",
|
|
19
|
+
"description": "Topic names to omit (exact match)",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"excluded_opinions": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"title": "Excluded Opinions",
|
|
27
|
+
"description": "Opinion names to omit (exact match)",
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "string"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/smoke.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { loadConfigSchema, LATEST_CONFIG_SCHEMA_VERSION } from "./data.js";
|
|
2
|
+
import { runBuild } from "./build.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
function assert(condition, message) {
|
|
9
|
+
if (!condition) {
|
|
10
|
+
throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const schema = loadConfigSchema(null, packageRoot);
|
|
16
|
+
assert(
|
|
17
|
+
schema.config_schema_version === LATEST_CONFIG_SCHEMA_VERSION,
|
|
18
|
+
`Expected config_schema_version ${LATEST_CONFIG_SCHEMA_VERSION}`,
|
|
19
|
+
);
|
|
20
|
+
for (const key of ["title", "logo", "excluded_topics", "excluded_opinions"]) {
|
|
21
|
+
assert(schema.properties?.[key], `Config schema missing property "${key}"`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const explicit = loadConfigSchema(1, packageRoot);
|
|
25
|
+
assert(explicit.config_schema_version === 1, "Expected schema version 1");
|
|
26
|
+
|
|
27
|
+
let failed = false;
|
|
28
|
+
try {
|
|
29
|
+
loadConfigSchema(99, packageRoot);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failed = true;
|
|
32
|
+
assert(
|
|
33
|
+
String(error.message).includes("Unknown config schema version"),
|
|
34
|
+
`Expected clear unknown-version error, got: ${error.message}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
assert(failed, "Expected unknown schema version to fail.");
|
|
38
|
+
|
|
39
|
+
const previousWrite = process.stdout.write.bind(process.stdout);
|
|
40
|
+
let captured = "";
|
|
41
|
+
process.stdout.write = (chunk) => {
|
|
42
|
+
captured += String(chunk);
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
await runBuild(["node", "build.js", "config-schema"], packageRoot);
|
|
47
|
+
} finally {
|
|
48
|
+
process.stdout.write = previousWrite;
|
|
49
|
+
}
|
|
50
|
+
const printed = JSON.parse(captured);
|
|
51
|
+
assert(
|
|
52
|
+
printed.config_schema_version === 1,
|
|
53
|
+
"config-schema command did not print v1 schema",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
console.log("Smoke tests passed.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
main().catch((error) => {
|
|
60
|
+
console.error(error.message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|