@forwardimpact/libsyntheticrender 0.1.1

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/render/raw.js ADDED
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Raw Document Renderer — generates individual JSON/YAML documents
3
+ * destined for Supabase Storage or local output.
4
+ *
5
+ * Produces: GitHub webhooks, GetDX API payloads, people YAML,
6
+ * roster YAML, and teams YAML.
7
+ */
8
+
9
+ import YAML from "yaml";
10
+
11
+ /**
12
+ * Render raw documents from entities.
13
+ * @param {object} entities
14
+ * @param {Map<string,string>} [proseMap] - Optional prose map for comment text
15
+ * @returns {Map<string,string>} storage-path → content
16
+ */
17
+ export function renderRawDocuments(entities, proseMap) {
18
+ const files = new Map();
19
+
20
+ renderGitHubWebhooks(entities, files);
21
+ renderGetDXPayloads(entities, files);
22
+ renderGetDXInitiatives(entities, files);
23
+ renderGetDXScorecards(entities, files);
24
+ renderGetDXComments(entities, files, proseMap);
25
+ renderRosterSnapshots(entities, files);
26
+ renderSummitYAML(entities, files);
27
+ renderPeopleYAML(entities, files);
28
+
29
+ return files;
30
+ }
31
+
32
+ /**
33
+ * Render activity files (roster + teams) from entities.
34
+ * @param {object} entities
35
+ * @returns {Map<string,string>} path → YAML content
36
+ */
37
+ export function renderActivityFiles(entities) {
38
+ const files = new Map();
39
+ files.set("roster.yaml", renderRoster(entities));
40
+ files.set("teams.yaml", renderTeams(entities));
41
+ return files;
42
+ }
43
+
44
+ /**
45
+ * Render GitHub webhook JSON payloads.
46
+ * @param {object} entities
47
+ * @param {Map<string,string>} files
48
+ */
49
+ function renderGitHubWebhooks(entities, files) {
50
+ if (!entities.activity?.webhooks) return;
51
+
52
+ for (const webhook of entities.activity.webhooks) {
53
+ const path = `github/${webhook.delivery_id}.json`;
54
+ files.set(path, JSON.stringify(webhook, null, 2));
55
+ }
56
+
57
+ // Index file for all webhooks
58
+ const index = entities.activity.webhooks.map((w) => ({
59
+ id: w.delivery_id,
60
+ type: w.event_type,
61
+ repo: w.payload?.repository?.full_name,
62
+ actor: w.payload?.sender?.login,
63
+ created_at: w.occurred_at,
64
+ }));
65
+ files.set("github/index.json", JSON.stringify(index, null, 2));
66
+ }
67
+
68
+ /**
69
+ * Render GetDX API payloads.
70
+ * @param {object} entities
71
+ * @param {Map<string,string>} files
72
+ */
73
+ function renderGetDXPayloads(entities, files) {
74
+ // teams.list
75
+ if (entities.activity?.activityTeams) {
76
+ const teamsList = entities.activity.activityTeams.map((t) => ({
77
+ id: t.getdx_team_id,
78
+ name: t.name,
79
+ parent_id: t.parent_id || null,
80
+ parent: t.is_parent || false,
81
+ manager_id: t.manager_id || null,
82
+ contributors: t.contributors || 0,
83
+ last_changed_at: t.last_changed_at || null,
84
+ reference_id: t.reference_id || null,
85
+ ancestors: t.ancestors || [],
86
+ }));
87
+ files.set(
88
+ "getdx/teams.list.json",
89
+ JSON.stringify({ ok: true, teams: teamsList }, null, 2),
90
+ );
91
+ }
92
+
93
+ // snapshots.list
94
+ if (entities.activity?.snapshots) {
95
+ const snapshotsList = entities.activity.snapshots.map((s) => ({
96
+ id: s.snapshot_id,
97
+ account_id: s.account_id,
98
+ last_result_change_at: s.last_result_change_at,
99
+ scheduled_for: s.scheduled_for,
100
+ completed_at: s.completed_at,
101
+ completed_count: s.completed_count,
102
+ deleted_at: s.deleted_at || null,
103
+ total_count: s.total_count,
104
+ }));
105
+ files.set(
106
+ "getdx/snapshots.list.json",
107
+ JSON.stringify({ ok: true, snapshots: snapshotsList }, null, 2),
108
+ );
109
+ }
110
+
111
+ // snapshots.info — one per snapshot with scores
112
+ if (entities.activity?.snapshots && entities.activity?.scores) {
113
+ const scoresBySnapshot = new Map();
114
+ for (const score of entities.activity.scores) {
115
+ const key = score.snapshot_id;
116
+ if (!scoresBySnapshot.has(key)) scoresBySnapshot.set(key, []);
117
+ scoresBySnapshot.get(key).push(score);
118
+ }
119
+
120
+ for (const [snapshotId, scores] of scoresBySnapshot) {
121
+ const teamScores = scores.map((s) => ({
122
+ snapshot_team: {
123
+ id: s.snapshot_team_id,
124
+ name: s.team_name,
125
+ team_id: s.getdx_team_id,
126
+ parent: s.is_parent || false,
127
+ parent_id: s.parent_id || null,
128
+ ancestors: s.ancestors || [],
129
+ },
130
+ item_id: s.item_id,
131
+ item_type: s.item_type,
132
+ item_name: s.item_name,
133
+ response_count: s.response_count,
134
+ score: s.score,
135
+ contributor_count: s.contributor_count,
136
+ vs_prev: s.vs_prev,
137
+ vs_org: s.vs_org,
138
+ vs_50th: s.vs_50th,
139
+ vs_75th: s.vs_75th,
140
+ vs_90th: s.vs_90th,
141
+ }));
142
+
143
+ files.set(
144
+ `getdx/snapshots/${snapshotId}.json`,
145
+ JSON.stringify(
146
+ { ok: true, snapshot: { team_scores: teamScores } },
147
+ null,
148
+ 2,
149
+ ),
150
+ );
151
+ }
152
+ }
153
+
154
+ // evidence
155
+ if (entities.activity?.evidence) {
156
+ files.set(
157
+ "getdx/evidence.json",
158
+ JSON.stringify({ evidence: entities.activity.evidence }, null, 2),
159
+ );
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Render GetDX initiatives API payloads.
165
+ * @param {object} entities
166
+ * @param {Map<string,string>} files
167
+ */
168
+ function renderGetDXInitiatives(entities, files) {
169
+ if (!entities.activity?.initiatives) return;
170
+
171
+ const initiatives = entities.activity.initiatives.map((init) => ({
172
+ id: init.id,
173
+ name: init.name,
174
+ description: init.description,
175
+ scorecard_id: init.scorecard_id,
176
+ scorecard_name: init.scorecard_name,
177
+ priority: init.priority,
178
+ published: init.published,
179
+ complete_by: init.complete_by,
180
+ percentage_complete: init.percentage_complete,
181
+ passed_checks: init.passed_checks,
182
+ total_checks: init.total_checks,
183
+ remaining_dev_days: init.remaining_dev_days,
184
+ owner: init.owner,
185
+ tags: init.tags,
186
+ }));
187
+
188
+ files.set(
189
+ "getdx/initiatives.list.json",
190
+ JSON.stringify({ ok: true, initiatives }, null, 2),
191
+ );
192
+
193
+ for (const init of initiatives) {
194
+ files.set(
195
+ `getdx/initiatives/${init.id}.json`,
196
+ JSON.stringify({ ok: true, initiative: init }, null, 2),
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Render GetDX scorecards API payloads.
203
+ * @param {object} entities
204
+ * @param {Map<string,string>} files
205
+ */
206
+ function renderGetDXScorecards(entities, files) {
207
+ if (!entities.activity?.scorecards) return;
208
+
209
+ const scorecards = entities.activity.scorecards.map((sc) => ({
210
+ id: sc.id,
211
+ name: sc.name,
212
+ description: sc.description,
213
+ type: sc.type,
214
+ published: sc.published,
215
+ checks: sc.checks,
216
+ levels: sc.levels,
217
+ tags: sc.tags,
218
+ entity_filter_type: "entity_types",
219
+ entity_filter_sql: null,
220
+ entity_filter_type_ids: [],
221
+ editors: [],
222
+ admins: [],
223
+ sql_errors: [],
224
+ empty_level_label: "Not assessed",
225
+ empty_level_color: "#9ca3af",
226
+ }));
227
+
228
+ files.set(
229
+ "getdx/scorecards.list.json",
230
+ JSON.stringify({ ok: true, scorecards }, null, 2),
231
+ );
232
+
233
+ for (const sc of scorecards) {
234
+ files.set(
235
+ `getdx/scorecards/${sc.id}.json`,
236
+ JSON.stringify({ ok: true, scorecard: sc }, null, 2),
237
+ );
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Render GetDX snapshot comments API payloads.
243
+ * Uses LLM-generated prose from the prose map when available,
244
+ * falls back to placeholder text.
245
+ * @param {object} entities
246
+ * @param {Map<string,string>} files
247
+ * @param {Map<string,string>} [proseMap]
248
+ */
249
+ function renderGetDXComments(entities, files, proseMap) {
250
+ if (!entities.activity?.commentKeys) return;
251
+
252
+ // Group comments by snapshot
253
+ const bySnapshot = new Map();
254
+ for (const ck of entities.activity.commentKeys) {
255
+ if (!bySnapshot.has(ck.snapshot_id)) bySnapshot.set(ck.snapshot_id, []);
256
+ bySnapshot.get(ck.snapshot_id).push(ck);
257
+ }
258
+
259
+ for (const [snapshotId, keys] of bySnapshot) {
260
+ const comments = keys.map((ck) => {
261
+ // Look up LLM-generated prose
262
+ const proseKey = `snapshot_comment_${ck.snapshot_id}_${ck.email.replace(/[@.]/g, "_")}`;
263
+ let text = null;
264
+ if (proseMap) {
265
+ for (const [k, v] of proseMap) {
266
+ if (k.includes(proseKey) || proseKey.includes(k)) {
267
+ text = v;
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ // Note: if text is still null, prose generation was not run for this key.
273
+ // The prose map uses hashed keys, so fallback iteration is not feasible.
274
+
275
+ return {
276
+ snapshot_id: ck.snapshot_id,
277
+ email: ck.email,
278
+ text:
279
+ text ||
280
+ `[${ck.driver_name} — ${ck.trajectory}] Comment pending prose generation.`,
281
+ timestamp: ck.timestamp,
282
+ team_id: ck.team_id,
283
+ };
284
+ });
285
+
286
+ files.set(
287
+ `getdx/snapshots/${snapshotId}/comments.json`,
288
+ JSON.stringify({ ok: true, comments }, null, 2),
289
+ );
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Render quarterly roster snapshots for Summit trajectory.
295
+ * @param {object} entities
296
+ * @param {Map<string,string>} files
297
+ */
298
+ function renderRosterSnapshots(entities, files) {
299
+ if (!entities.activity?.rosterSnapshots) return;
300
+
301
+ const snapshots = entities.activity.rosterSnapshots.map((rs) => ({
302
+ quarter: rs.quarter,
303
+ members: rs.members,
304
+ changes: rs.changes,
305
+ roster: rs.roster,
306
+ }));
307
+
308
+ files.set(
309
+ "activity/roster-snapshots.json",
310
+ JSON.stringify({ roster_snapshots: snapshots }, null, 2),
311
+ );
312
+
313
+ for (const rs of snapshots) {
314
+ files.set(
315
+ `activity/roster-snapshots/${rs.quarter}.yaml`,
316
+ YAML.stringify(
317
+ {
318
+ quarter: rs.quarter,
319
+ members: rs.members,
320
+ changes: rs.changes,
321
+ roster: rs.roster,
322
+ },
323
+ { lineWidth: 120 },
324
+ ),
325
+ );
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Render summit.yaml with project teams and allocation.
331
+ * @param {object} entities
332
+ * @param {Map<string,string>} files
333
+ */
334
+ function renderSummitYAML(entities, files) {
335
+ if (!entities.activity?.projectTeams) return;
336
+
337
+ const teams = {};
338
+ for (const pt of entities.activity.projectTeams) {
339
+ teams[pt.id] = pt.members.map((m) => ({
340
+ name: m.name,
341
+ email: m.email,
342
+ job: m.job,
343
+ ...(m.allocation !== 1.0 ? { allocation: m.allocation } : {}),
344
+ }));
345
+ }
346
+
347
+ // Also include reporting teams from roster
348
+ const reportingTeams = {};
349
+ if (entities.activity?.roster) {
350
+ const teamMap = new Map();
351
+ for (const person of entities.activity.roster) {
352
+ if (!teamMap.has(person.team_id)) teamMap.set(person.team_id, []);
353
+ teamMap.get(person.team_id).push({
354
+ name: person.name,
355
+ email: person.email,
356
+ job: {
357
+ discipline: person.discipline,
358
+ level: person.level,
359
+ ...(person.track ? { track: person.track } : {}),
360
+ },
361
+ });
362
+ }
363
+ for (const [teamId, members] of teamMap) {
364
+ reportingTeams[teamId] = members;
365
+ }
366
+ }
367
+
368
+ const summitData = { teams: reportingTeams, projects: teams };
369
+ files.set(
370
+ "activity/summit.yaml",
371
+ YAML.stringify(summitData, { lineWidth: 120 }),
372
+ );
373
+ }
374
+
375
+ /**
376
+ * Render individual people YAML files.
377
+ * @param {object} entities
378
+ * @param {Map<string,string>} files
379
+ */
380
+ function renderPeopleYAML(entities, files) {
381
+ for (const person of entities.people) {
382
+ const team = entities.teams.find((t) => t.id === person.team_id);
383
+ const dept = entities.departments.find((d) => d.id === person.department);
384
+
385
+ const data = {
386
+ id: person.id,
387
+ name: person.name,
388
+ email: person.email,
389
+ github: person.github,
390
+ iri: person.iri,
391
+ discipline: person.discipline,
392
+ level: person.level,
393
+ team: { id: team?.id, name: team?.name },
394
+ department: { id: dept?.id, name: dept?.name },
395
+ hire_date: person.hire_date,
396
+ is_manager: person.is_manager || false,
397
+ };
398
+
399
+ files.set(
400
+ `people/${person.id}.yaml`,
401
+ YAML.stringify(data, { lineWidth: 120 }),
402
+ );
403
+ }
404
+
405
+ // People index
406
+ const index = entities.people.map((p) => ({
407
+ id: p.id,
408
+ name: p.name,
409
+ team: p.team_id,
410
+ level: p.level,
411
+ }));
412
+ files.set("people/index.json", JSON.stringify(index, null, 2));
413
+ }
414
+
415
+ /**
416
+ * Render roster YAML for GetDX integration.
417
+ * @param {object} entities
418
+ * @returns {string}
419
+ */
420
+ function renderRoster(entities) {
421
+ const roster = entities.people.map((person) => ({
422
+ id: person.id,
423
+ name: person.name,
424
+ email: person.email,
425
+ github: person.github,
426
+ team: person.team_id,
427
+ department: person.department,
428
+ discipline: person.discipline,
429
+ level: person.level,
430
+ hire_date: person.hire_date,
431
+ is_manager: person.is_manager || false,
432
+ }));
433
+
434
+ return YAML.stringify({ roster }, { lineWidth: 120 });
435
+ }
436
+
437
+ /**
438
+ * Render teams YAML.
439
+ * @param {object} entities
440
+ * @returns {string}
441
+ */
442
+ function renderTeams(entities) {
443
+ const teams = entities.teams.map((team) => {
444
+ const dept = entities.departments.find((d) => d.id === team.department);
445
+ const members = entities.people.filter((p) => p.team_id === team.id);
446
+ const manager = members.find((m) => m.is_manager);
447
+
448
+ return {
449
+ id: team.id,
450
+ name: team.name,
451
+ department: team.department,
452
+ department_name: dept?.name || "",
453
+ manager: manager?.name || "",
454
+ manager_email: manager?.email || "",
455
+ size: team.size,
456
+ members: members.map((m) => ({
457
+ name: m.name,
458
+ email: m.email,
459
+ level: m.level,
460
+ })),
461
+ };
462
+ });
463
+
464
+ return YAML.stringify({ teams }, { lineWidth: 120 });
465
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Renderer — wraps all render functions behind a single class with DI.
3
+ *
4
+ * @module libuniverse/render/renderer
5
+ */
6
+
7
+ import { dirname, join } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { TemplateLoader } from "@forwardimpact/libtemplate/loader";
10
+ import { renderHTML, renderREADME, renderONTOLOGY } from "./html.js";
11
+ import { renderRawDocuments, renderActivityFiles } from "./raw.js";
12
+ import { renderPathway } from "./pathway.js";
13
+ import { renderMarkdown } from "./markdown.js";
14
+ import { enrichDocuments } from "./enricher.js";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ /**
19
+ * Renderer class that delegates to individual render modules.
20
+ */
21
+ export class Renderer {
22
+ /**
23
+ * @param {import('@forwardimpact/libtemplate/loader').TemplateLoader} templateLoader - Template loader
24
+ * @param {object} logger - Logger instance
25
+ */
26
+ constructor(templateLoader, logger) {
27
+ if (!templateLoader) throw new Error("templateLoader is required");
28
+ if (!logger) throw new Error("logger is required");
29
+ this.templateLoader = templateLoader;
30
+ this.logger = logger;
31
+ }
32
+
33
+ /**
34
+ * Render HTML microdata files from entities and prose.
35
+ * @param {object} entities
36
+ * @param {Map<string,string>} prose
37
+ * @returns {{ files: Map<string,string>, linked: object }}
38
+ */
39
+ renderHtml(entities, prose) {
40
+ return renderHTML(entities, prose, this.templateLoader);
41
+ }
42
+
43
+ /**
44
+ * Render organization README.
45
+ * @param {object} entities
46
+ * @param {Map<string,string>} prose
47
+ * @returns {string}
48
+ */
49
+ renderReadme(entities, prose) {
50
+ return renderREADME(entities, prose, this.templateLoader);
51
+ }
52
+
53
+ /**
54
+ * Render ONTOLOGY.md with entity IRIs.
55
+ * @param {object} entities
56
+ * @returns {string}
57
+ */
58
+ renderOntology(entities) {
59
+ return renderONTOLOGY(entities, this.templateLoader);
60
+ }
61
+
62
+ /**
63
+ * Render Markdown files for Basecamp personas.
64
+ * @param {object} entities
65
+ * @param {Map<string,string>} prose
66
+ * @returns {Map<string,string>}
67
+ */
68
+ renderMarkdown(entities, prose) {
69
+ return renderMarkdown(entities, prose, this.templateLoader);
70
+ }
71
+
72
+ /**
73
+ * Render raw documents from entities.
74
+ * @param {object} entities
75
+ * @param {Map<string,string>} [proseMap] - Optional prose map for comment text
76
+ * @returns {Map<string,string>}
77
+ */
78
+ renderRaw(entities, proseMap) {
79
+ return renderRawDocuments(entities, proseMap);
80
+ }
81
+
82
+ /**
83
+ * Render activity files (roster + teams) from entities.
84
+ * @param {object} entities
85
+ * @returns {Map<string,string>}
86
+ */
87
+ renderActivity(entities) {
88
+ return renderActivityFiles(entities);
89
+ }
90
+
91
+ /**
92
+ * Render pathway YAML files from generated entity data.
93
+ * @param {object} pathwayData
94
+ * @returns {Map<string,string>}
95
+ */
96
+ renderPathway(pathwayData) {
97
+ return renderPathway(pathwayData);
98
+ }
99
+
100
+ /**
101
+ * Enrich HTML documents with LLM-generated prose.
102
+ * @param {Map<string,string>} htmlFiles
103
+ * @param {object} linked - LinkedEntities
104
+ * @param {import('../engine/prose.js').ProseEngine} proseEngine
105
+ * @param {string} domain
106
+ * @returns {Promise<Map<string,string>>}
107
+ */
108
+ async enrichHtml(htmlFiles, linked, proseEngine, domain) {
109
+ return enrichDocuments(htmlFiles, linked, proseEngine, domain, this.logger);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Creates a Renderer with real dependencies wired.
115
+ * @param {object} logger - Logger instance
116
+ * @returns {Renderer}
117
+ */
118
+ export function createRenderer(logger) {
119
+ const templateDir = join(__dirname, "..", "templates");
120
+ const templateLoader = new TemplateLoader(templateDir);
121
+ return new Renderer(templateLoader, logger);
122
+ }