@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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Link Assigner — deterministic cross-link assignment between entities.
3
+ *
4
+ * Assigns drugs to projects, platforms to projects, people to events/courses,
5
+ * and builds all cross-entity relationships using seeded random for
6
+ * reproducibility.
7
+ *
8
+ * @module libuniverse/render/link-assigner
9
+ */
10
+
11
+ import { createSeededRNG } from "@forwardimpact/libsyntheticgen/rng";
12
+
13
+ /**
14
+ * @typedef {object} LinkedEntities
15
+ * @property {object[]} drugs
16
+ * @property {object[]} platforms
17
+ * @property {object[]} projects - enriched with drug/platform/people links
18
+ * @property {object[]} courses - enriched with prereqs, attendees, platform/drug links
19
+ * @property {object[]} events - enriched with organizer, attendees, about links
20
+ * @property {object[]} blogPosts - enriched with author, about, mentions
21
+ */
22
+
23
+ /**
24
+ * Assign cross-links between all entity types deterministically.
25
+ * @param {object} params
26
+ * @param {object[]} params.drugs
27
+ * @param {object[]} params.platforms
28
+ * @param {object[]} params.projects
29
+ * @param {object[]} params.people
30
+ * @param {object[]} params.teams
31
+ * @param {object[]} params.departments
32
+ * @param {string} params.domain
33
+ * @param {number} params.courseCount
34
+ * @param {number} params.eventCount
35
+ * @param {number} params.blogCount
36
+ * @param {string[]} [params.articleTopics=[]]
37
+ * @param {number} [params.seed=42]
38
+ * @returns {LinkedEntities}
39
+ */
40
+ export function assignLinks({
41
+ drugs,
42
+ platforms,
43
+ projects,
44
+ people,
45
+ teams,
46
+ departments,
47
+ domain,
48
+ courseCount,
49
+ eventCount,
50
+ blogCount,
51
+ articleTopics = [],
52
+ seed = 42,
53
+ }) {
54
+ const rng = createSeededRNG(seed + 1000);
55
+ const base = `https://${domain}`;
56
+
57
+ // --- Project linking ---
58
+ const linkedProjects = projects.map((proj) => {
59
+ const projectTeams = teams.filter((t) => proj.teams.includes(t.id));
60
+ const projectPeople = people.filter((p) =>
61
+ projectTeams.some((t) => t.id === p.team_id),
62
+ );
63
+ const leader =
64
+ projectPeople.find((p) => p.is_manager) || rng.pick(projectPeople);
65
+ const members = rng
66
+ .shuffle(projectPeople)
67
+ .slice(0, Math.min(8, projectPeople.length));
68
+
69
+ // Assign drugs to drug/platform projects
70
+ const drugLinks =
71
+ proj.type === "drug"
72
+ ? [drugs.find((d) => d.id === proj.id) || rng.pick(drugs)]
73
+ : [rng.pick(drugs)];
74
+ const platformLinks =
75
+ proj.type === "platform"
76
+ ? [platforms.find((p) => p.id === proj.id) || rng.pick(platforms)]
77
+ : rng.shuffle(platforms).slice(0, 2);
78
+ const deptLinks = [...new Set(projectTeams.map((t) => t.department))]
79
+ .map((dId) => departments.find((d) => d.id === dId))
80
+ .filter(Boolean);
81
+
82
+ return {
83
+ ...proj,
84
+ iri: `${base}/id/project/${proj.id}`,
85
+ leader,
86
+ members,
87
+ drugLinks,
88
+ platformLinks,
89
+ departmentLinks: deptLinks,
90
+ };
91
+ });
92
+
93
+ // --- Course linking ---
94
+ const COURSE_CATALOG = [
95
+ {
96
+ id: "PHARM-101",
97
+ title: "Introduction to Drug Discovery",
98
+ category: "Drug Discovery",
99
+ },
100
+ {
101
+ id: "PHARM-201",
102
+ title: "Clinical Data Management",
103
+ category: "Clinical",
104
+ },
105
+ {
106
+ id: "PHARM-301",
107
+ title: "Advanced Pharmacology",
108
+ category: "Drug Discovery",
109
+ },
110
+ {
111
+ id: "GMP-101",
112
+ title: "GMP Compliance Essentials",
113
+ category: "Manufacturing",
114
+ },
115
+ {
116
+ id: "GMP-201",
117
+ title: "GMP Advanced Practices",
118
+ category: "Manufacturing",
119
+ },
120
+ {
121
+ id: "STAT-101",
122
+ title: "Pharmaceutical Statistics",
123
+ category: "Analytics",
124
+ },
125
+ {
126
+ id: "STAT-201",
127
+ title: "Biostatistics for Trials",
128
+ category: "Analytics",
129
+ },
130
+ {
131
+ id: "BIO-101",
132
+ title: "Molecular Biology Fundamentals",
133
+ category: "Genomics",
134
+ },
135
+ { id: "BIO-201", title: "Genomics and Sequencing", category: "Genomics" },
136
+ { id: "REG-101", title: "Regulatory Submissions", category: "Regulatory" },
137
+ {
138
+ id: "DATA-101",
139
+ title: "Data Engineering for Pharma",
140
+ category: "Data Infrastructure",
141
+ },
142
+ { id: "DATA-201", title: "Machine Learning Pipelines", category: "AI/ML" },
143
+ { id: "AI-101", title: "AI in Drug Development", category: "AI/ML" },
144
+ { id: "QA-101", title: "Quality Assurance Methods", category: "Quality" },
145
+ {
146
+ id: "SEC-101",
147
+ title: "Cloud Infrastructure Security",
148
+ category: "Security",
149
+ },
150
+ ];
151
+
152
+ const coursesToGenerate = Math.min(courseCount, COURSE_CATALOG.length);
153
+ const linkedCourses = COURSE_CATALOG.slice(0, coursesToGenerate).map(
154
+ (course, i) => {
155
+ // Build prerequisite chains: 201 courses require 101 in same category
156
+ const prereqs = [];
157
+ if (course.id.includes("201")) {
158
+ const base101 = COURSE_CATALOG.find(
159
+ (c) => c.id.includes("101") && c.category === course.category,
160
+ );
161
+ if (base101) prereqs.push(base101.id);
162
+ }
163
+ if (course.id.includes("301")) {
164
+ const base201 = COURSE_CATALOG.find(
165
+ (c) => c.id.includes("201") && c.category === course.category,
166
+ );
167
+ if (base201) prereqs.push(base201.id);
168
+ }
169
+
170
+ // Assign platform and drug links by category
171
+ const relatedPlatforms = platforms.filter(
172
+ (p) => p.category === course.category,
173
+ );
174
+ const platformLink =
175
+ relatedPlatforms.length > 0
176
+ ? rng.pick(relatedPlatforms)
177
+ : rng.pick(platforms);
178
+ const drugLink = rng.pick(drugs);
179
+
180
+ // Assign attendees
181
+ const attendees = rng.shuffle(people).slice(0, rng.randomInt(3, 8));
182
+
183
+ return {
184
+ ...course,
185
+ index: i + 1,
186
+ iri: `${base}/id/course/${course.id}`,
187
+ prerequisites: prereqs,
188
+ prerequisiteIris: prereqs.map((pid) => `${base}/id/course/${pid}`),
189
+ platformLink,
190
+ drugLink,
191
+ attendees,
192
+ date: `2025-${String((i % 12) + 1).padStart(2, "0")}-01`,
193
+ orgName: "BioNova",
194
+ orgIri: `${base}/org/headquarters`,
195
+ };
196
+ },
197
+ );
198
+
199
+ // --- Event linking ---
200
+ const EVENT_CATALOG = [
201
+ "Engineering All-Hands",
202
+ "Tech Talk: AI in Pharma",
203
+ "Hackathon 2025",
204
+ "Architecture Review Board",
205
+ "Sprint Demo Day",
206
+ "Drug Discovery Symposium",
207
+ "Compliance Training Day",
208
+ "Platform Migration Workshop",
209
+ "Data Science Summit",
210
+ "Security Awareness Week",
211
+ ];
212
+ const eventsToGenerate = Math.min(eventCount, EVENT_CATALOG.length);
213
+ const linkedEvents = Array.from({ length: eventsToGenerate }, (_, i) => {
214
+ const organizer = rng.pick(people.filter((p) => p.is_manager));
215
+ const attendees = rng.shuffle(people).slice(0, rng.randomInt(5, 15));
216
+ const aboutProjects = rng
217
+ .shuffle(linkedProjects)
218
+ .slice(0, rng.randomInt(1, 3));
219
+ const aboutDrugs = rng.shuffle(drugs).slice(0, rng.randomInt(0, 2));
220
+ const aboutPlatforms = rng.shuffle(platforms).slice(0, rng.randomInt(1, 2));
221
+
222
+ return {
223
+ index: i + 1,
224
+ title: EVENT_CATALOG[i % EVENT_CATALOG.length],
225
+ iri: `${base}/id/event/event-${i + 1}`,
226
+ organizer,
227
+ attendees,
228
+ aboutProjects,
229
+ aboutDrugs,
230
+ aboutPlatforms,
231
+ date: `2025-${String((i % 12) + 1).padStart(2, "0")}-15`,
232
+ location: "Cambridge, MA",
233
+ eventStatus: "EventScheduled",
234
+ };
235
+ });
236
+
237
+ // --- Blog post linking ---
238
+ const BLOG_TOPICS = [
239
+ "AI-Driven Drug Discovery at BioNova",
240
+ "Our Journey to Cloud-Native Infrastructure",
241
+ "Building a Culture of Engineering Excellence",
242
+ "How We Scaled Our Clinical Data Pipeline",
243
+ "Machine Learning in Pharmaceutical Manufacturing",
244
+ "Security Best Practices for Life Sciences",
245
+ "Developer Experience: What We Learned",
246
+ "The Future of Genomics Technology",
247
+ "Cross-Functional Collaboration in Drug Development",
248
+ "Real-World Evidence and Data Analytics",
249
+ "Platform Engineering at Scale",
250
+ "Regulatory Technology Innovation",
251
+ "Quality Engineering in a GMP Environment",
252
+ "Data Mesh Architecture for Pharma",
253
+ "Open Source in Pharmaceutical R&D",
254
+ ];
255
+
256
+ const linkedBlogPosts = Array.from({ length: blogCount }, (_, i) => {
257
+ const author = rng.pick(people);
258
+ const aboutDrugs = rng.shuffle(drugs).slice(0, rng.randomInt(1, 3));
259
+ const aboutPlatforms = rng.shuffle(platforms).slice(0, rng.randomInt(1, 3));
260
+ const aboutProjects = rng
261
+ .shuffle(linkedProjects)
262
+ .slice(0, rng.randomInt(0, 2));
263
+ const mentionsPeople = rng
264
+ .shuffle(people.filter((p) => p.id !== author.id))
265
+ .slice(0, rng.randomInt(1, 4));
266
+
267
+ const keywords = [
268
+ ...aboutDrugs.map((d) => d.name),
269
+ ...aboutPlatforms.map((p) => p.name),
270
+ ];
271
+
272
+ return {
273
+ index: i + 1,
274
+ headline: BLOG_TOPICS[i % BLOG_TOPICS.length],
275
+ iri: `${base}/id/blog/blog-${i + 1}`,
276
+ identifier: `BLOG-2025-${String(i + 1).padStart(3, "0")}`,
277
+ author,
278
+ aboutDrugs,
279
+ aboutPlatforms,
280
+ aboutProjects,
281
+ mentionsPeople,
282
+ keywords: keywords.join(", "),
283
+ date: `2025-${String(Math.floor(i / 2) + 1).padStart(2, "0")}-${String(10 + (i % 20)).padStart(2, "0")}`,
284
+ };
285
+ });
286
+
287
+ // --- Article linking ---
288
+ const TOPIC_ENTITY_MAP = {
289
+ clinical: {
290
+ drugFilter: (d) =>
291
+ ["oncora", "cardioguard", "immunex-pro"].includes(d.id),
292
+ platformCategory: "Clinical",
293
+ },
294
+ data_ai: { drugFilter: () => true, platformCategory: "AI/ML" },
295
+ drug_discovery: {
296
+ drugFilter: () => true,
297
+ platformCategory: "Drug Discovery",
298
+ },
299
+ manufacturing: {
300
+ drugFilter: (d) => ["genova-rna", "dermashield"].includes(d.id),
301
+ platformCategory: "Manufacturing",
302
+ },
303
+ };
304
+
305
+ const linkedArticles = articleTopics.map((topic, i) => {
306
+ const topicTitle = topic
307
+ .replace(/_/g, " ")
308
+ .replace(/\b\w/g, (c) => c.toUpperCase());
309
+ const mapping = TOPIC_ENTITY_MAP[topic] || {
310
+ drugFilter: () => true,
311
+ platformCategory: null,
312
+ };
313
+
314
+ const drugLinks = drugs.filter(mapping.drugFilter).slice(0, 3);
315
+ const platformLinks = mapping.platformCategory
316
+ ? platforms
317
+ .filter((p) => p.category === mapping.platformCategory)
318
+ .slice(0, 3)
319
+ : rng.shuffle(platforms).slice(0, 3);
320
+ const projectLinks = rng.shuffle(linkedProjects).slice(0, 2);
321
+ const authorLinks = rng.shuffle(people).slice(0, 2);
322
+
323
+ return {
324
+ topic,
325
+ title: topicTitle,
326
+ iri: `${base}/id/article/${topic}`,
327
+ identifier: `ART-${topic.toUpperCase().replace(/_/g, "-")}-001`,
328
+ author: authorLinks[0],
329
+ authorLinks,
330
+ drugLinks,
331
+ platformLinks,
332
+ projectLinks,
333
+ keywords: [
334
+ ...drugLinks.map((d) => d.name),
335
+ ...platformLinks.map((p) => p.name),
336
+ ].join(", "),
337
+ date: `2025-${String((i % 12) + 1).padStart(2, "0")}-01`,
338
+ };
339
+ });
340
+
341
+ return {
342
+ drugs,
343
+ platforms,
344
+ projects: linkedProjects,
345
+ courses: linkedCourses,
346
+ events: linkedEvents,
347
+ blogPosts: linkedBlogPosts,
348
+ articles: linkedArticles,
349
+ };
350
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Markdown Renderer — generates personal knowledge-base content
3
+ * for Basecamp personas.
4
+ *
5
+ * Uses TemplateLoader from libtemplate for all output.
6
+ */
7
+
8
+ const SKILL_NAMES = [
9
+ "version_control",
10
+ "code_review",
11
+ "testing",
12
+ "deployment",
13
+ "monitoring",
14
+ "documentation",
15
+ "architecture",
16
+ "security",
17
+ ];
18
+ const PROFICIENCIES = [
19
+ "awareness",
20
+ "foundational",
21
+ "working",
22
+ "practitioner",
23
+ "expert",
24
+ ];
25
+ const LEVEL_IDX = { L1: 0, L2: 1, L3: 2, L4: 3, L5: 4 };
26
+
27
+ /**
28
+ * Render Markdown files for Basecamp personas.
29
+ * @param {object} entities
30
+ * @param {Map<string,string>} prose
31
+ * @param {import('@forwardimpact/libtemplate/loader').TemplateLoader} templates - Template loader
32
+ * @returns {Map<string,string>} path → Markdown content
33
+ */
34
+ export function renderMarkdown(entities, prose, templates) {
35
+ if (!templates) throw new Error("templates is required");
36
+ const files = new Map();
37
+ const basecampContent = entities.content.find(
38
+ (c) => c.id === "basecamp_markdown",
39
+ );
40
+ if (!basecampContent) return files;
41
+
42
+ const personaCount = basecampContent.personas || 0;
43
+ const personaLevels = basecampContent.persona_levels || ["L2", "L3", "L4"];
44
+ const candidates = entities.people.filter((p) =>
45
+ personaLevels.includes(p.level),
46
+ );
47
+ const personas = candidates.slice(0, personaCount);
48
+ const date = new Date().toISOString().split("T")[0];
49
+
50
+ for (const person of personas) {
51
+ const team = entities.teams.find((t) => t.id === person.team_id);
52
+ const dept = entities.departments.find((d) => d.id === person.department);
53
+ const prefix = `personas/${person.id}`;
54
+ const baseIdx = LEVEL_IDX[person.level] || 0;
55
+
56
+ const ctx = {
57
+ personId: person.id,
58
+ personName: person.name,
59
+ discipline: person.discipline,
60
+ level: person.level,
61
+ email: person.email,
62
+ teamName: team?.name || "Unknown",
63
+ deptName: dept?.name || "Unknown",
64
+ teamSize: team?.size || "?",
65
+ isManager: person.is_manager ? "Yes" : "No",
66
+ date,
67
+ };
68
+
69
+ files.set(
70
+ `${prefix}/daily-briefing.md`,
71
+ templates.render("briefing.md", {
72
+ ...ctx,
73
+ briefing:
74
+ prose.get(`briefing_${person.id}`) ||
75
+ `Morning briefing for ${person.name}, ${person.discipline} ${person.level} on ${team?.name || "their team"}.`,
76
+ }),
77
+ );
78
+
79
+ files.set(
80
+ `${prefix}/weekly-notes.md`,
81
+ templates.render("weekly.md", {
82
+ ...ctx,
83
+ weeklyNote:
84
+ prose.get(`weekly_${person.id}`) ||
85
+ `Weekly reflection for ${person.name}.`,
86
+ }),
87
+ );
88
+
89
+ files.set(
90
+ `${prefix}/skill-reflections.md`,
91
+ templates.render("skill-reflection.md", {
92
+ ...ctx,
93
+ skills: SKILL_NAMES.map((s) => ({
94
+ label: s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
95
+ proficiency: PROFICIENCIES[Math.min(4, baseIdx)],
96
+ })),
97
+ growthNote:
98
+ baseIdx < 4
99
+ ? `Target: Move from ${PROFICIENCIES[baseIdx]} to ${PROFICIENCIES[baseIdx + 1]} in key areas.`
100
+ : "Currently at expert level. Focus on mentoring and knowledge sharing.",
101
+ }),
102
+ );
103
+
104
+ const personProjects = entities.projects.filter((proj) =>
105
+ (proj.teams || []).includes(person.team_id),
106
+ );
107
+ for (const proj of personProjects) {
108
+ files.set(
109
+ `${prefix}/project-${proj.id}.md`,
110
+ templates.render("project-note.md", {
111
+ ...ctx,
112
+ projectId: proj.id,
113
+ projectName: proj.name,
114
+ projectType: proj.type,
115
+ timeline_start: proj.timeline_start,
116
+ timeline_end: proj.timeline_end,
117
+ note:
118
+ prose.get(`project_note_${person.id}_${proj.id}`) ||
119
+ `Notes on ${proj.name} from ${person.name}'s perspective.`,
120
+ }),
121
+ );
122
+ }
123
+ }
124
+
125
+ return files;
126
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Pathway Renderer — converts LLM-generated JSON to YAML files.
3
+ *
4
+ * @module libuniverse/render/pathway
5
+ */
6
+
7
+ import YAML from "yaml";
8
+
9
+ /**
10
+ * Render pathway YAML files from generated entity data.
11
+ *
12
+ * @param {object} pathwayData - Keyed by entity type
13
+ * @returns {Map<string,string>} path → YAML content
14
+ */
15
+ export function renderPathway(pathwayData) {
16
+ const files = new Map();
17
+
18
+ // Single-file entities
19
+ if (pathwayData.framework) {
20
+ files.set("framework.yaml", toYaml(pathwayData.framework, "framework"));
21
+ }
22
+ if (pathwayData.levels) {
23
+ files.set("levels.yaml", toYaml(pathwayData.levels, "levels"));
24
+ }
25
+ if (pathwayData.stages) {
26
+ files.set("stages.yaml", toYaml(pathwayData.stages, "stages"));
27
+ }
28
+ if (pathwayData.drivers) {
29
+ files.set("drivers.yaml", toYaml(pathwayData.drivers, "drivers"));
30
+ }
31
+ if (pathwayData.selfAssessments) {
32
+ files.set(
33
+ "self-assessments.yaml",
34
+ toYaml(pathwayData.selfAssessments, "self-assessments"),
35
+ );
36
+ }
37
+
38
+ // Multi-file entities (one YAML per entity)
39
+ if (pathwayData.capabilities) {
40
+ const capIds = [];
41
+ for (const cap of pathwayData.capabilities) {
42
+ const id = cap._id || cap.id;
43
+ const data = stripInternal(cap);
44
+ files.set(`capabilities/${id}.yaml`, toYaml(data, "capability"));
45
+ capIds.push(id);
46
+ }
47
+ files.set("capabilities/_index.yaml", renderIndex(capIds));
48
+ }
49
+
50
+ if (pathwayData.behaviours) {
51
+ const behIds = [];
52
+ for (const beh of pathwayData.behaviours) {
53
+ const id = beh._id || beh.id;
54
+ const data = stripInternal(beh);
55
+ files.set(`behaviours/${id}.yaml`, toYaml(data, "behaviour"));
56
+ behIds.push(id);
57
+ }
58
+ files.set("behaviours/_index.yaml", renderIndex(behIds));
59
+ }
60
+
61
+ if (pathwayData.disciplines) {
62
+ const discIds = [];
63
+ for (const disc of pathwayData.disciplines) {
64
+ const id = disc._id || disc.id;
65
+ const data = stripInternal(disc);
66
+ files.set(`disciplines/${id}.yaml`, toYaml(data, "discipline"));
67
+ discIds.push(id);
68
+ }
69
+ files.set("disciplines/_index.yaml", renderIndex(discIds));
70
+ }
71
+
72
+ if (pathwayData.tracks) {
73
+ const trackIds = [];
74
+ for (const track of pathwayData.tracks) {
75
+ const id = track._id || track.id;
76
+ const data = stripInternal(track);
77
+ files.set(`tracks/${id}.yaml`, toYaml(data, "track"));
78
+ trackIds.push(id);
79
+ }
80
+ files.set("tracks/_index.yaml", renderIndex(trackIds));
81
+ }
82
+
83
+ return files;
84
+ }
85
+
86
+ /**
87
+ * Convert data to YAML with a schema comment header.
88
+ *
89
+ * @param {object} data
90
+ * @param {string} schemaName
91
+ * @returns {string}
92
+ */
93
+ function toYaml(data, schemaName) {
94
+ const schemaComment = `# yaml-language-server: $schema=https://www.forwardimpact.team/schema/json/${schemaName}.schema.json\n\n`;
95
+ return schemaComment + YAML.stringify(data, { lineWidth: 80 });
96
+ }
97
+
98
+ /**
99
+ * Render an _index.yaml file.
100
+ *
101
+ * @param {string[]} ids
102
+ * @returns {string}
103
+ */
104
+ function renderIndex(ids) {
105
+ const content = [
106
+ "# Auto-generated index for browser loading",
107
+ "# Do not edit manually - regenerate with: npx pathway --generate-index",
108
+ ];
109
+ return content.join("\n") + "\n" + YAML.stringify({ files: ids });
110
+ }
111
+
112
+ /**
113
+ * Strip internal properties (prefixed with _) from an object.
114
+ *
115
+ * @param {object} obj
116
+ * @returns {object}
117
+ */
118
+ function stripInternal(obj) {
119
+ const result = {};
120
+ for (const [key, value] of Object.entries(obj)) {
121
+ if (!key.startsWith("_")) result[key] = value;
122
+ }
123
+ return result;
124
+ }