@forwardimpact/libsyntheticprose 0.1.2 → 0.1.4

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/engine/pathway.js CHANGED
@@ -18,6 +18,10 @@ import { buildCapabilityPrompt } from "../prompts/pathway/capability.js";
18
18
  import { buildDriverPrompt } from "../prompts/pathway/driver.js";
19
19
  import { buildDisciplinePrompt } from "../prompts/pathway/discipline.js";
20
20
  import { buildTrackPrompt } from "../prompts/pathway/track.js";
21
+ import {
22
+ PROFICIENCY_LEVELS,
23
+ MATURITY_LEVELS,
24
+ } from "@forwardimpact/libsyntheticgen/vocabulary.js";
21
25
 
22
26
  /**
23
27
  * Load JSON schemas from the schema directory.
@@ -99,7 +103,10 @@ async function generatePathwayData({
99
103
  schemas,
100
104
  proseEngine,
101
105
  }) {
102
- const ctx = { domain, industry };
106
+ const frameworkName = framework.name || domain;
107
+ const ctx = { domain, industry, frameworkName };
108
+ const BASE_TOKENS = 2000;
109
+ const PER_SKILL_TOKENS = 800;
103
110
 
104
111
  // 1. Framework metadata
105
112
  const fw = await generateEntity(
@@ -107,6 +114,7 @@ async function generatePathwayData({
107
114
  "framework",
108
115
  buildFrameworkPrompt(framework, ctx, schemas.framework),
109
116
  proseEngine,
117
+ { maxTokens: BASE_TOKENS },
110
118
  );
111
119
 
112
120
  // 2. Levels
@@ -123,15 +131,19 @@ async function generatePathwayData({
123
131
  "stages",
124
132
  buildStagePrompt(framework.stages, ctx, schemas.stages),
125
133
  proseEngine,
134
+ { maxTokens: BASE_TOKENS },
126
135
  );
127
136
 
128
- // 4. Behaviours (parallel no cross-references)
137
+ // Build prior output context for downstream prompts
138
+ const priorOutput = { levels };
139
+
140
+ // 4. Behaviours (parallel — receive level context)
129
141
  const behaviours = await Promise.all(
130
142
  framework.behaviours.map((b) =>
131
143
  generateEntity(
132
144
  "behaviour",
133
145
  b.id,
134
- buildBehaviourPrompt(b, ctx, schemas.behaviour),
146
+ buildBehaviourPrompt(b, ctx, schemas.behaviour, priorOutput),
135
147
  proseEngine,
136
148
  ).then((data) => ({
137
149
  ...data,
@@ -140,7 +152,9 @@ async function generatePathwayData({
140
152
  ),
141
153
  );
142
154
 
143
- // 5. Capabilities with skills (parallel)
155
+ priorOutput.behaviours = behaviours;
156
+
157
+ // 5. Capabilities with skills (parallel — receive level + behaviour context)
144
158
  const capabilities = await Promise.all(
145
159
  framework.capabilities.map((c, i) =>
146
160
  generateEntity(
@@ -150,8 +164,10 @@ async function generatePathwayData({
150
164
  { ...c, ordinalRank: i + 1 },
151
165
  ctx,
152
166
  schemas.capability,
167
+ priorOutput,
153
168
  ),
154
169
  proseEngine,
170
+ { maxTokens: BASE_TOKENS + (c.skills || []).length * PER_SKILL_TOKENS },
155
171
  ).then((data) => ({
156
172
  ...data,
157
173
  _id: c.id,
@@ -159,6 +175,8 @@ async function generatePathwayData({
159
175
  ),
160
176
  );
161
177
 
178
+ priorOutput.capabilities = capabilities;
179
+
162
180
  // Collect all skill IDs and behaviour IDs from DSL declarations
163
181
  // (not from LLM output — these must be available even in no-prose mode)
164
182
  const skillIds = framework.capabilities.flatMap((c) => c.skills || []);
@@ -174,6 +192,7 @@ async function generatePathwayData({
174
192
  schemas.drivers,
175
193
  ),
176
194
  proseEngine,
195
+ { maxTokens: BASE_TOKENS },
177
196
  );
178
197
 
179
198
  // 7. Disciplines (reference skills, behaviours, track IDs from DSL)
@@ -187,6 +206,7 @@ async function generatePathwayData({
187
206
  d,
188
207
  { ...ctx, skillIds, behaviourIds, trackIds },
189
208
  schemas.discipline,
209
+ priorOutput,
190
210
  ),
191
211
  proseEngine,
192
212
  ).then((data) => ({
@@ -207,6 +227,7 @@ async function generatePathwayData({
207
227
  t,
208
228
  { ...ctx, capabilityIds, skillIds, behaviourIds },
209
229
  schemas.track,
230
+ priorOutput,
210
231
  ),
211
232
  proseEngine,
212
233
  ).then((data) => ({
@@ -245,12 +266,22 @@ async function generatePathwayData({
245
266
  * @param {import('./prose.js').ProseEngine} proseEngine - Prose engine
246
267
  * @returns {Promise<object|null>} Parsed JSON data
247
268
  */
248
- async function generateEntity(entityType, entityId, prompt, proseEngine) {
269
+ async function generateEntity(
270
+ entityType,
271
+ entityId,
272
+ prompt,
273
+ proseEngine,
274
+ { maxTokens } = {},
275
+ ) {
249
276
  const key = `pathway:${entityType}:${entityId}`;
250
- const result = await proseEngine.generateJson(key, [
251
- { role: "system", content: prompt.system },
252
- { role: "user", content: prompt.user },
253
- ]);
277
+ const result = await proseEngine.generateJson(
278
+ key,
279
+ [
280
+ { role: "system", content: prompt.system },
281
+ { role: "user", content: prompt.user },
282
+ ],
283
+ maxTokens ? { maxTokens } : undefined,
284
+ );
254
285
  return result;
255
286
  }
256
287
 
@@ -302,20 +333,8 @@ function jitter(rng, base, max) {
302
333
  * @returns {object[]}
303
334
  */
304
335
  function generateSelfAssessments(framework, skillIds, behaviourIds) {
305
- const proficiencies = framework.proficiencies || [
306
- "awareness",
307
- "foundational",
308
- "working",
309
- "practitioner",
310
- "expert",
311
- ];
312
- const maturities = framework.maturities || [
313
- "emerging",
314
- "developing",
315
- "practicing",
316
- "role_modeling",
317
- "exemplifying",
318
- ];
336
+ const proficiencies = framework.proficiencies || PROFICIENCY_LEVELS;
337
+ const maturities = framework.maturities || MATURITY_LEVELS;
319
338
 
320
339
  const seed = framework.seed || 1;
321
340
  const rng = createRng(seed);
@@ -325,10 +344,18 @@ function generateSelfAssessments(framework, skillIds, behaviourIds) {
325
344
  const assessments = [];
326
345
  const levelNames = ["junior", "mid", "senior", "staff", "principal"];
327
346
 
347
+ // Track previous level's indices per skill/behaviour to enforce monotonicity
348
+ const prevSkillIdx = {};
349
+ const prevBehIdx = {};
350
+
328
351
  for (let i = 0; i < Math.min(levelNames.length, proficiencies.length); i++) {
329
352
  const skillProficiencies = {};
330
353
  for (const skillId of skillIds) {
331
- skillProficiencies[skillId] = proficiencies[jitter(rng, i, maxP)];
354
+ const raw = jitter(rng, i, maxP);
355
+ const floor = prevSkillIdx[skillId] ?? 0;
356
+ const idx = Math.max(floor, raw);
357
+ skillProficiencies[skillId] = proficiencies[idx];
358
+ prevSkillIdx[skillId] = idx;
332
359
  }
333
360
 
334
361
  const behaviourMaturities = {};
@@ -339,8 +366,11 @@ function generateSelfAssessments(framework, skillIds, behaviourIds) {
339
366
  if (r < 0.55) offset = 0;
340
367
  else if (r < 0.8) offset = 1;
341
368
  else offset = -1;
342
- behaviourMaturities[behaviourId] =
343
- maturities[Math.max(0, Math.min(maxM, i + offset))];
369
+ const raw = Math.max(0, Math.min(maxM, i + offset));
370
+ const floor = prevBehIdx[behaviourId] ?? 0;
371
+ const idx = Math.max(floor, raw);
372
+ behaviourMaturities[behaviourId] = maturities[idx];
373
+ prevBehIdx[behaviourId] = idx;
344
374
  }
345
375
 
346
376
  assessments.push({
package/engine/prose.js CHANGED
@@ -17,7 +17,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  export class ProseEngine {
18
18
  /**
19
19
  * @param {object} options
20
- * @param {string} options.cachePath Path to .prose-cache.json
20
+ * @param {string} options.cachePath Path to prose cache JSON file
21
21
  * @param {string} options.mode "cached" | "generate" | "no-prose"
22
22
  * @param {boolean} [options.strict] Fail on cache miss
23
23
  * @param {import('@forwardimpact/libllm').LlmApi} options.llmApi
@@ -108,7 +108,7 @@ export class ProseEngine {
108
108
  * @param {object[]} messages - Pre-built messages array [{role, content}]
109
109
  * @returns {Promise<string|null>}
110
110
  */
111
- async generateStructured(key, messages) {
111
+ async generateStructured(key, messages, { maxTokens = 4000 } = {}) {
112
112
  if (this.mode === "no-prose") return null;
113
113
 
114
114
  const cacheKey = generateHash(key, JSON.stringify(messages));
@@ -127,7 +127,7 @@ export class ProseEngine {
127
127
 
128
128
  const response = await this.llmApi.createCompletions({
129
129
  messages,
130
- max_tokens: 4000,
130
+ max_tokens: maxTokens,
131
131
  });
132
132
  const content = response.choices?.[0]?.message?.content?.trim() || null;
133
133
  this.stats.generated++;
@@ -147,8 +147,8 @@ export class ProseEngine {
147
147
  * @param {object[]} messages - Pre-built messages array
148
148
  * @returns {Promise<object|null>}
149
149
  */
150
- async generateJson(key, messages) {
151
- const raw = await this.generateStructured(key, messages);
150
+ async generateJson(key, messages, options) {
151
+ const raw = await this.generateStructured(key, messages, options);
152
152
  if (!raw) return null;
153
153
  const cleaned = raw
154
154
  .replace(/^```(?:json)?\s*\n?/m, "")
@@ -183,6 +183,7 @@ export class ProseEngine {
183
183
  tone: context.tone || "technical",
184
184
  length: context.length || "2-3 paragraphs",
185
185
  domain: context.domain,
186
+ orgName: context.orgName,
186
187
  role: context.role,
187
188
  audience: context.audience,
188
189
  scenario: context.scenario,
@@ -209,7 +210,7 @@ export class ProseEngine {
209
210
  /**
210
211
  * Creates a ProseEngine with real dependencies wired.
211
212
  * @param {object} options
212
- * @param {string} options.cachePath - Path to .prose-cache.json
213
+ * @param {string} options.cachePath - Path to prose cache JSON file
213
214
  * @param {string} options.mode - "cached" | "generate" | "no-prose"
214
215
  * @param {boolean} [options.strict] - Fail on cache miss
215
216
  * @param {import('@forwardimpact/libllm').LlmApi} [options.llmApi] - LLM client
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libsyntheticprose",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "LLM-based prose and pathway generation for synthetic data",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,3 +1,6 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+ import { MATURITY_LEVELS } from "@forwardimpact/libsyntheticgen/vocabulary.js";
3
+
1
4
  /**
2
5
  * Prompt template for a single behaviour entity.
3
6
  *
@@ -6,14 +9,17 @@
6
9
  * @param {object} schema - JSON schema for behaviour
7
10
  * @returns {{ system: string, user: string }}
8
11
  */
9
- export function buildBehaviourPrompt(skeleton, ctx, schema) {
12
+ export function buildBehaviourPrompt(skeleton, ctx, schema, priorOutput) {
10
13
  return {
11
- system: [
12
- "You are an expert career framework author.",
13
- "Output ONLY valid JSON. No markdown fences, no explanations.",
14
- `The organization domain is: ${ctx.domain}.`,
15
- `Industry: ${ctx.industry}.`,
16
- ].join(" "),
14
+ system:
15
+ buildPreamble(ctx.frameworkName || ctx.domain) +
16
+ "\n\n" +
17
+ [
18
+ "You are an expert career framework author.",
19
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
20
+ `The organization domain is: ${ctx.domain}.`,
21
+ `Industry: ${ctx.industry}.`,
22
+ ].join(" "),
17
23
 
18
24
  user: [
19
25
  "Generate a behaviour definition for a career framework.",
@@ -29,13 +35,27 @@ export function buildBehaviourPrompt(skeleton, ctx, schema) {
29
35
  "- name: Use the provided name exactly.",
30
36
  "- human.description: 2-3 sentences describing this behaviour.",
31
37
  "- human.maturityDescriptions: One paragraph per maturity level",
32
- " (emerging, developing, practicing, role_modeling, exemplifying).",
38
+ ` (${MATURITY_LEVELS.join(", ")}).`,
33
39
  ' Use second-person ("You..."). Each level must show clear',
34
40
  " progression in depth, consistency, and influence.",
35
41
  "- agent.title: Short title (2-4 words) for how the agent applies this behaviour.",
36
42
  "- agent.workingStyle: 1-2 sentences describing how the AI agent should embody",
37
43
  " this behaviour in its work style and communication.",
38
44
  "",
45
+ ...(priorOutput?.levels
46
+ ? [
47
+ "",
48
+ "## Previously generated context",
49
+ "Level titles and proficiency baselines:",
50
+ ...(Array.isArray(priorOutput.levels)
51
+ ? priorOutput.levels.map(
52
+ (l) =>
53
+ `- ${l.id}: ${l.professionalTitle || l.id} (primary: ${l.baseSkillProficiencies?.primary || "N/A"})`,
54
+ )
55
+ : []),
56
+ ]
57
+ : []),
58
+ "",
39
59
  "Output a single JSON object for this behaviour file.",
40
60
  ].join("\n"),
41
61
  };
@@ -1,3 +1,9 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+ import {
3
+ PROFICIENCY_LEVELS,
4
+ STAGE_NAMES,
5
+ } from "@forwardimpact/libsyntheticgen/vocabulary.js";
6
+
1
7
  /**
2
8
  * Prompt template for a single capability entity (with skills).
3
9
  *
@@ -6,14 +12,17 @@
6
12
  * @param {object} schema - JSON schema for capability
7
13
  * @returns {{ system: string, user: string }}
8
14
  */
9
- export function buildCapabilityPrompt(skeleton, ctx, schema) {
15
+ export function buildCapabilityPrompt(skeleton, ctx, schema, priorOutput) {
10
16
  return {
11
- system: [
12
- "You are an expert career framework author.",
13
- "Output ONLY valid JSON. No markdown fences, no explanations.",
14
- `The organization domain is: ${ctx.domain}.`,
15
- `Industry: ${ctx.industry}.`,
16
- ].join(" "),
17
+ system:
18
+ buildPreamble(ctx.frameworkName || ctx.domain) +
19
+ "\n\n" +
20
+ [
21
+ "You are an expert career framework author.",
22
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
23
+ `The organization domain is: ${ctx.domain}.`,
24
+ `Industry: ${ctx.industry}.`,
25
+ ].join(" "),
17
26
 
18
27
  user: [
19
28
  "Generate a capability definition for a career framework.",
@@ -36,14 +45,14 @@ export function buildCapabilityPrompt(skeleton, ctx, schema) {
36
45
  `- ordinalRank: ${skeleton.ordinalRank}`,
37
46
  "- description: 1-2 sentences describing this capability area.",
38
47
  "- professionalResponsibilities: One sentence per proficiency level",
39
- " (awareness through expert) describing IC expectations.",
48
+ ` (${PROFICIENCY_LEVELS.join(" through ")}) describing IC expectations.`,
40
49
  "- managementResponsibilities: Same for management track.",
41
50
  "- skills: For each skill ID listed above, generate:",
42
51
  " - id: Use the provided skill ID exactly.",
43
52
  " - name: Human-readable name (title case).",
44
53
  " - human.description: 2-3 sentences.",
45
54
  " - human.proficiencyDescriptions: One paragraph per level",
46
- " (awareness, foundational, working, practitioner, expert).",
55
+ ` (${PROFICIENCY_LEVELS.join(", ")}).`,
47
56
  ' Use second-person ("You..."). Each level must show clear',
48
57
  " progression in scope, autonomy, and complexity.",
49
58
  "- For each skill, also generate an agent section:",
@@ -51,19 +60,43 @@ export function buildCapabilityPrompt(skeleton, ctx, schema) {
51
60
  " - agent.description: 1 sentence describing what this agent skill provides.",
52
61
  " - agent.useWhen: 1 sentence describing when/why an agent should use this skill.",
53
62
  " - agent.stages: Object with ONLY the stages where this skill is meaningfully relevant.",
54
- " Not all skills need all 6 stages. Use these criteria:",
55
- " - specify: include if the skill informs what to build or constrains requirements",
56
- " - plan: include if the skill drives architecture or design decisions",
57
- " - onboard: include if the skill requires tooling, dependencies, or env setup",
58
- " - code: include if the skill is directly exercised during implementation",
59
- " - review: include if the skill has quality criteria to verify",
60
- " - deploy: include if the skill has production or operational concerns",
63
+ ` Not all skills need all ${STAGE_NAMES.length} stages. Use these criteria:`,
64
+ ` - ${STAGE_NAMES[0]}: include if the skill informs what to build or constrains requirements`,
65
+ ` - ${STAGE_NAMES[1]}: include if the skill drives architecture or design decisions`,
66
+ ` - ${STAGE_NAMES[2]}: include if the skill requires tooling, dependencies, or env setup`,
67
+ ` - ${STAGE_NAMES[3]}: include if the skill is directly exercised during implementation`,
68
+ ` - ${STAGE_NAMES[4]}: include if the skill has quality criteria to verify`,
69
+ ` - ${STAGE_NAMES[5]}: include if the skill has production or operational concerns`,
61
70
  " Each skill must have at least 2 stages. Omit stages where the skill has no specific guidance.",
62
71
  " Each stage has:",
63
72
  " - focus: 1 sentence — the primary focus for this skill in this stage.",
64
73
  " - readChecklist: Array of 2-3 items — steps to read/understand before acting.",
65
74
  " - confirmChecklist: Array of 2-3 items — items to verify after completing work.",
66
75
  "",
76
+ ...(priorOutput?.levels || priorOutput?.behaviours
77
+ ? [
78
+ "",
79
+ "## Previously generated context",
80
+ ...(priorOutput.levels && Array.isArray(priorOutput.levels)
81
+ ? [
82
+ "Level titles and proficiency baselines:",
83
+ ...priorOutput.levels.map(
84
+ (l) =>
85
+ `- ${l.id}: ${l.professionalTitle || l.id} (primary: ${l.baseSkillProficiencies?.primary || "N/A"})`,
86
+ ),
87
+ ]
88
+ : []),
89
+ ...(priorOutput.behaviours && Array.isArray(priorOutput.behaviours)
90
+ ? [
91
+ "Behaviour names:",
92
+ ...priorOutput.behaviours.map(
93
+ (b) => `- ${b._id || b.id}: ${b.name || b._id || b.id}`,
94
+ ),
95
+ ]
96
+ : []),
97
+ ]
98
+ : []),
99
+ "",
67
100
  "Output the JSON object for this single capability file.",
68
101
  ].join("\n"),
69
102
  };
@@ -1,3 +1,5 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+
1
3
  /**
2
4
  * Prompt template for a single discipline entity.
3
5
  *
@@ -6,14 +8,17 @@
6
8
  * @param {object} schema - JSON schema for discipline
7
9
  * @returns {{ system: string, user: string }}
8
10
  */
9
- export function buildDisciplinePrompt(skeleton, ctx, schema) {
11
+ export function buildDisciplinePrompt(skeleton, ctx, schema, priorOutput) {
10
12
  return {
11
- system: [
12
- "You are an expert career framework author.",
13
- "Output ONLY valid JSON. No markdown fences, no explanations.",
14
- `The organization domain is: ${ctx.domain}.`,
15
- `Industry: ${ctx.industry}.`,
16
- ].join(" "),
13
+ system:
14
+ buildPreamble(ctx.frameworkName || ctx.domain) +
15
+ "\n\n" +
16
+ [
17
+ "You are an expert career framework author.",
18
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
19
+ `The organization domain is: ${ctx.domain}.`,
20
+ `Industry: ${ctx.industry}.`,
21
+ ].join(" "),
17
22
 
18
23
  user: [
19
24
  "Generate a discipline definition for a career framework.",
@@ -51,10 +56,45 @@ export function buildDisciplinePrompt(skeleton, ctx, schema) {
51
56
  " Include 2-3 behaviour modifiers relevant to this discipline.",
52
57
  "- human.roleSummary: 2-3 sentences describing this role. May use {roleTitle} or {specialization}.",
53
58
  "- agent.identity: 1-2 sentences defining the AI coding agent's core identity.",
54
- " Frame as 'You are a {roleTitle} agent that...' May use {roleTitle} or {roleName} placeholder.",
59
+ " Frame as 'You are a {roleTitle} agent that...' May use {roleTitle} or {specialization} placeholder.",
55
60
  "- agent.priority: 1 sentence stating the agent's top priority (e.g., code quality, system reliability).",
56
61
  "- agent.constraints: 2-3 things the agent must avoid or never do.",
57
62
  "",
63
+ ...(priorOutput?.levels ||
64
+ priorOutput?.behaviours ||
65
+ priorOutput?.capabilities
66
+ ? [
67
+ "",
68
+ "## Previously generated context",
69
+ ...(priorOutput.levels && Array.isArray(priorOutput.levels)
70
+ ? [
71
+ "Level titles:",
72
+ ...priorOutput.levels.map(
73
+ (l) => `- ${l.id}: ${l.professionalTitle || l.id}`,
74
+ ),
75
+ ]
76
+ : []),
77
+ ...(priorOutput.behaviours && Array.isArray(priorOutput.behaviours)
78
+ ? [
79
+ "Behaviour names:",
80
+ ...priorOutput.behaviours.map(
81
+ (b) => `- ${b._id || b.id}: ${b.name || b._id || b.id}`,
82
+ ),
83
+ ]
84
+ : []),
85
+ ...(priorOutput.capabilities &&
86
+ Array.isArray(priorOutput.capabilities)
87
+ ? [
88
+ "Capability names and skill IDs:",
89
+ ...priorOutput.capabilities.map(
90
+ (c) =>
91
+ `- ${c._id || c.id}: ${c.name || c._id || c.id} (skills: ${(c.skills || []).map((s) => s.id || s).join(", ")})`,
92
+ ),
93
+ ]
94
+ : []),
95
+ ]
96
+ : []),
97
+ "",
58
98
  "Output a single JSON object for this discipline.",
59
99
  ].join("\n"),
60
100
  };
@@ -1,3 +1,5 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+
1
3
  /**
2
4
  * Prompt template for drivers.yaml — all drivers in a single call.
3
5
  *
@@ -18,12 +20,15 @@ export function buildDriverPrompt(drivers, ctx, schema) {
18
20
  .join("\n");
19
21
 
20
22
  return {
21
- system: [
22
- "You are an expert career framework author.",
23
- "Output ONLY valid JSON. No markdown fences, no explanations.",
24
- `The organization domain is: ${ctx.domain}.`,
25
- `Industry: ${ctx.industry}.`,
26
- ].join(" "),
23
+ system:
24
+ buildPreamble(ctx.frameworkName || ctx.domain) +
25
+ "\n\n" +
26
+ [
27
+ "You are an expert career framework author.",
28
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
29
+ `The organization domain is: ${ctx.domain}.`,
30
+ `Industry: ${ctx.industry}.`,
31
+ ].join(" "),
27
32
 
28
33
  user: [
29
34
  "Generate organizational driver definitions for a career framework.",
@@ -1,19 +1,24 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+
1
3
  /**
2
4
  * Prompt template for framework.yaml metadata.
3
5
  *
4
6
  * @param {object} skeleton - Framework skeleton from DSL
5
- * @param {object} ctx - Universe context (domain, industry)
7
+ * @param {object} ctx - Universe context (domain, industry, frameworkName)
6
8
  * @param {object} schema - JSON schema for framework entity
7
9
  * @returns {{ system: string, user: string }}
8
10
  */
9
11
  export function buildFrameworkPrompt(skeleton, ctx, schema) {
10
12
  return {
11
- system: [
12
- "You are an expert career framework author.",
13
- "Output ONLY valid JSON. No markdown fences, no explanations.",
14
- `The organization domain is: ${ctx.domain}.`,
15
- `Industry: ${ctx.industry}.`,
16
- ].join(" "),
13
+ system:
14
+ buildPreamble(ctx.frameworkName || ctx.domain) +
15
+ "\n\n" +
16
+ [
17
+ "You are an expert career framework author.",
18
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
19
+ `The organization domain is: ${ctx.domain}.`,
20
+ `Industry: ${ctx.industry}.`,
21
+ ].join(" "),
17
22
 
18
23
  user: [
19
24
  "Generate a framework metadata file for an engineering career framework.",
@@ -32,6 +37,7 @@ export function buildFrameworkPrompt(skeleton, ctx, schema) {
32
37
  "- entityDefinitions: Provide definitions for these entity types:",
33
38
  " driver, skill, behaviour, discipline, level, track, job, agent, stage, tool.",
34
39
  " Each needs: title, emojiIcon, description (1 sentence).",
40
+ ' IMPORTANT: A "stage" is a lifecycle phase of work delivery (specify, plan, scaffold, code, review, deploy) — NOT a maturity or progression level.',
35
41
  "",
36
42
  "Output a single JSON object.",
37
43
  ].join("\n"),
@@ -1,3 +1,9 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+ import {
3
+ PROFICIENCY_LEVELS,
4
+ MATURITY_LEVELS,
5
+ } from "@forwardimpact/libsyntheticgen/vocabulary.js";
6
+
1
7
  /**
2
8
  * Prompt template for levels.yaml — all levels in a single call.
3
9
  *
@@ -15,12 +21,15 @@ export function buildLevelPrompt(levels, ctx, schema) {
15
21
  .join("\n");
16
22
 
17
23
  return {
18
- system: [
19
- "You are an expert career framework author.",
20
- "Output ONLY valid JSON. No markdown fences, no explanations.",
21
- `The organization domain is: ${ctx.domain}.`,
22
- `Industry: ${ctx.industry}.`,
23
- ].join(" "),
24
+ system:
25
+ buildPreamble(ctx.frameworkName || ctx.domain) +
26
+ "\n\n" +
27
+ [
28
+ "You are an expert career framework author.",
29
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
30
+ `The organization domain is: ${ctx.domain}.`,
31
+ `Industry: ${ctx.industry}.`,
32
+ ].join(" "),
24
33
 
25
34
  user: [
26
35
  "Generate career level definitions for an engineering pathway.",
@@ -44,9 +53,9 @@ export function buildLevelPrompt(levels, ctx, schema) {
44
53
  " - qualificationSummary: 2-3 sentences describing qualifications.",
45
54
  " May use {typicalExperienceRange} placeholder.",
46
55
  " - baseSkillProficiencies: { primary, secondary, broad } using",
47
- " awareness/foundational/working/practitioner/expert.",
48
- " Increase across levels (L1→awareness, L5→expert for primary).",
49
- " - baseBehaviourMaturity: emerging/developing/practicing/role_modeling/exemplifying.",
56
+ ` ${PROFICIENCY_LEVELS.join("/")}.`,
57
+ ` Increase across levels (L1→${PROFICIENCY_LEVELS[0]}, L5→${PROFICIENCY_LEVELS.at(-1)} for primary).`,
58
+ ` - baseBehaviourMaturity: ${MATURITY_LEVELS.join("/")}.`,
50
59
  " Increase across levels.",
51
60
  " - expectations: { impactScope, autonomyExpectation, influenceScope, complexityHandled }.",
52
61
  " Each 1 sentence showing clear progression.",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared system preamble for all pathway prompt builders.
3
+ *
4
+ * Establishes consistent voice, terminology, and naming conventions
5
+ * across all 8 entity prompt builders.
6
+ */
7
+
8
+ import {
9
+ PROFICIENCY_LEVELS,
10
+ MATURITY_LEVELS,
11
+ } from "@forwardimpact/libsyntheticgen/vocabulary.js";
12
+
13
+ /**
14
+ * Build a shared system preamble for pathway prompt builders.
15
+ * @param {string} frameworkName - Name of the framework (or domain fallback)
16
+ * @returns {string}
17
+ */
18
+ export function buildPreamble(frameworkName) {
19
+ return [
20
+ `You are writing content for the "${frameworkName}" engineering career framework.`,
21
+ `Use these exact proficiency level names: ${PROFICIENCY_LEVELS.join(", ")}.`,
22
+ `Use these exact maturity level names: ${MATURITY_LEVELS.join(", ")}.`,
23
+ `Write in professional, concise, third-person voice.`,
24
+ `Use consistent terminology across all entities — prefer precise terms over synonyms.`,
25
+ ].join("\n");
26
+ }
@@ -1,3 +1,5 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+
1
3
  /**
2
4
  * Prompt template for stages.yaml — all stages in a single call.
3
5
  *
@@ -8,12 +10,15 @@
8
10
  */
9
11
  export function buildStagePrompt(stageIds, ctx, schema) {
10
12
  return {
11
- system: [
12
- "You are an expert career framework author.",
13
- "Output ONLY valid JSON. No markdown fences, no explanations.",
14
- `The organization domain is: ${ctx.domain}.`,
15
- `Industry: ${ctx.industry}.`,
16
- ].join(" "),
13
+ system:
14
+ buildPreamble(ctx.frameworkName || ctx.domain) +
15
+ "\n\n" +
16
+ [
17
+ "You are an expert career framework author.",
18
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
19
+ `The organization domain is: ${ctx.domain}.`,
20
+ `Industry: ${ctx.industry}.`,
21
+ ].join(" "),
17
22
 
18
23
  user: [
19
24
  "Generate engineering lifecycle stage definitions.",
@@ -28,13 +33,15 @@ export function buildStagePrompt(stageIds, ctx, schema) {
28
33
  "## Instructions",
29
34
  "- Output a JSON array of stage objects.",
30
35
  "- For each stage ID, generate:",
31
- " - id: The stage ID (must be one of: specify, plan, onboard, code, review, deploy).",
36
+ ` - id: The stage ID (must be one of: ${stageIds.join(", ")}).`,
32
37
  ' - name: Human-readable name (e.g., "Specify", "Plan").',
33
38
  " - emojiIcon: A single emoji for this stage.",
34
39
  ' - description: 2-3 sentences in second person ("You...").',
35
40
  " - summary: 1 sentence in third person.",
36
41
  " - handoffs: Array of transitions to other stages, each with:",
37
42
  " targetStage, label (button text), prompt (instructions for next stage).",
43
+ ` targetStage in each handoff MUST be one of the stage IDs listed above: ${stageIds.join(", ")}.`,
44
+ " Do not invent stage IDs that are not in the provided list.",
38
45
  " - constraints: 2-3 restrictions on behaviour in this stage.",
39
46
  " - readChecklist: 2-4 Read-Then-Do steps.",
40
47
  " - confirmChecklist: 2-4 Do-Then-Confirm items.",
@@ -1,3 +1,5 @@
1
+ import { buildPreamble } from "./preamble.js";
2
+
1
3
  /**
2
4
  * Prompt template for a single track entity.
3
5
  *
@@ -6,14 +8,17 @@
6
8
  * @param {object} schema - JSON schema for track
7
9
  * @returns {{ system: string, user: string }}
8
10
  */
9
- export function buildTrackPrompt(skeleton, ctx, schema) {
11
+ export function buildTrackPrompt(skeleton, ctx, schema, priorOutput) {
10
12
  return {
11
- system: [
12
- "You are an expert career framework author.",
13
- "Output ONLY valid JSON. No markdown fences, no explanations.",
14
- `The organization domain is: ${ctx.domain}.`,
15
- `Industry: ${ctx.industry}.`,
16
- ].join(" "),
13
+ system:
14
+ buildPreamble(ctx.frameworkName || ctx.domain) +
15
+ "\n\n" +
16
+ [
17
+ "You are an expert career framework author.",
18
+ "Output ONLY valid JSON. No markdown fences, no explanations.",
19
+ `The organization domain is: ${ctx.domain}.`,
20
+ `Industry: ${ctx.industry}.`,
21
+ ].join(" "),
17
22
 
18
23
  user: [
19
24
  "Generate a track definition for a career framework.",
@@ -45,6 +50,41 @@ export function buildTrackPrompt(skeleton, ctx, schema) {
45
50
  "- agent.priority: 1 sentence stating the track-specific priority.",
46
51
  "- agent.constraints: 1-2 additional constraints specific to this track.",
47
52
  "",
53
+ ...(priorOutput?.levels ||
54
+ priorOutput?.behaviours ||
55
+ priorOutput?.capabilities
56
+ ? [
57
+ "",
58
+ "## Previously generated context",
59
+ ...(priorOutput.levels && Array.isArray(priorOutput.levels)
60
+ ? [
61
+ "Level titles:",
62
+ ...priorOutput.levels.map(
63
+ (l) => `- ${l.id}: ${l.professionalTitle || l.id}`,
64
+ ),
65
+ ]
66
+ : []),
67
+ ...(priorOutput.behaviours && Array.isArray(priorOutput.behaviours)
68
+ ? [
69
+ "Behaviour names:",
70
+ ...priorOutput.behaviours.map(
71
+ (b) => `- ${b._id || b.id}: ${b.name || b._id || b.id}`,
72
+ ),
73
+ ]
74
+ : []),
75
+ ...(priorOutput.capabilities &&
76
+ Array.isArray(priorOutput.capabilities)
77
+ ? [
78
+ "Capability names and skill IDs:",
79
+ ...priorOutput.capabilities.map(
80
+ (c) =>
81
+ `- ${c._id || c.id}: ${c.name || c._id || c.id} (skills: ${(c.skills || []).map((s) => s.id || s).join(", ")})`,
82
+ ),
83
+ ]
84
+ : []),
85
+ ]
86
+ : []),
87
+ "",
48
88
  "Output a single JSON object for this track.",
49
89
  ].join("\n"),
50
90
  };
@@ -1,5 +1,7 @@
1
- Write {{length}} of {{tone}} prose about: {{topic}}. {{#domain}} Company domain:
2
- {{domain}}. {{/domain}} {{#role}} Written from the perspective of: {{role}}.
1
+ Write {{length}} of {{tone}} prose about: {{topic}}. {{#orgName}} Company name:
2
+ {{orgName}} (always use this exact capitalization). {{/orgName}} {{#domain}}
3
+ Company domain: {{domain}} (use only in URLs, never as the company name in
4
+ prose). {{/domain}} {{#role}} Written from the perspective of: {{role}}.
3
5
  {{/role}} {{#audience}} Target audience: {{audience}}. {{/audience}}
4
6
  {{#scenario}} Context: during "{{scenario}}", the {{driver}} driver is
5
7
  {{direction}} (magnitude: {{magnitude}}). {{/scenario}} Output the text only, no
@@ -0,0 +1,176 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { PathwayGenerator } from "../engine/pathway.js";
4
+
5
+ function makeLogger() {
6
+ return {
7
+ info: () => {},
8
+ debug: () => {},
9
+ warn: () => {},
10
+ error: () => {},
11
+ };
12
+ }
13
+
14
+ /**
15
+ * Mock ProseEngine that returns fixture data for each entity type.
16
+ */
17
+ function makeMockProseEngine() {
18
+ return {
19
+ generateJson: async (key) => {
20
+ if (key.includes("framework")) {
21
+ return { title: "Test", emojiIcon: "🏗️", tag: "#test" };
22
+ }
23
+ if (key.includes("levels")) {
24
+ return [
25
+ {
26
+ id: "J040",
27
+ professionalTitle: "Engineer",
28
+ baseSkillProficiencies: { primary: "awareness" },
29
+ },
30
+ ];
31
+ }
32
+ if (key.includes("stages")) {
33
+ return [{ id: "specify", name: "Specify" }];
34
+ }
35
+ if (key.includes("behaviour")) {
36
+ return { name: "Collaboration" };
37
+ }
38
+ if (key.includes("capability")) {
39
+ return {
40
+ name: "Coding",
41
+ skills: [{ id: "python", name: "Python" }],
42
+ };
43
+ }
44
+ if (key.includes("drivers")) {
45
+ return [{ id: "clear_direction", name: "Clear Direction" }];
46
+ }
47
+ if (key.includes("discipline")) {
48
+ return { roleTitle: "Software Engineer" };
49
+ }
50
+ if (key.includes("track")) {
51
+ return { name: "Backend" };
52
+ }
53
+ return null;
54
+ },
55
+ };
56
+ }
57
+
58
+ describe("PathwayGenerator", () => {
59
+ test("requires proseEngine and logger", () => {
60
+ assert.throws(() => new PathwayGenerator(null, makeLogger()));
61
+ assert.throws(() => new PathwayGenerator(makeMockProseEngine(), null));
62
+ });
63
+
64
+ test("generates all entity types", async () => {
65
+ const generator = new PathwayGenerator(makeMockProseEngine(), makeLogger());
66
+
67
+ const framework = {
68
+ name: "Test",
69
+ levels: [{ id: "J040", professionalTitle: "Engineer", rank: 1 }],
70
+ stages: ["specify", "plan"],
71
+ behaviours: [{ id: "collab", name: "Collaboration" }],
72
+ capabilities: [{ id: "coding", name: "Coding", skills: ["python"] }],
73
+ drivers: [
74
+ { id: "clear_direction", name: "Clear Direction", skills: ["python"] },
75
+ ],
76
+ disciplines: [
77
+ {
78
+ id: "se",
79
+ roleTitle: "Software Engineer",
80
+ core: ["python"],
81
+ supporting: [],
82
+ broad: [],
83
+ },
84
+ ],
85
+ tracks: [{ id: "backend", name: "Backend" }],
86
+ seed: 42,
87
+ };
88
+
89
+ const schemas = {
90
+ framework: {},
91
+ levels: {},
92
+ stages: {},
93
+ behaviour: {},
94
+ capability: {},
95
+ drivers: {},
96
+ discipline: {},
97
+ track: {},
98
+ "self-assessments": {},
99
+ defs: {},
100
+ };
101
+
102
+ const result = await generator.generate({
103
+ framework,
104
+ domain: "test.example",
105
+ industry: "pharma",
106
+ schemas,
107
+ });
108
+
109
+ assert.ok(result.framework);
110
+ assert.ok(result.levels);
111
+ assert.ok(result.stages);
112
+ assert.ok(Array.isArray(result.behaviours));
113
+ assert.ok(Array.isArray(result.capabilities));
114
+ assert.ok(result.drivers);
115
+ assert.ok(Array.isArray(result.disciplines));
116
+ assert.ok(Array.isArray(result.tracks));
117
+ assert.ok(Array.isArray(result.selfAssessments));
118
+ });
119
+
120
+ test("self-assessments use vocabulary constants", async () => {
121
+ const generator = new PathwayGenerator(makeMockProseEngine(), makeLogger());
122
+
123
+ const framework = {
124
+ name: "Test",
125
+ levels: [{ id: "J040", rank: 1 }],
126
+ stages: ["specify"],
127
+ behaviours: [{ id: "collab", name: "Collaboration" }],
128
+ capabilities: [{ id: "coding", name: "Coding", skills: ["python"] }],
129
+ drivers: [],
130
+ disciplines: [],
131
+ tracks: [],
132
+ seed: 42,
133
+ };
134
+ const schemas = {
135
+ framework: {},
136
+ levels: {},
137
+ stages: {},
138
+ behaviour: {},
139
+ capability: {},
140
+ drivers: {},
141
+ discipline: {},
142
+ track: {},
143
+ };
144
+
145
+ const result = await generator.generate({
146
+ framework,
147
+ domain: "test.example",
148
+ industry: "pharma",
149
+ schemas,
150
+ });
151
+
152
+ const validProficiencies = new Set([
153
+ "awareness",
154
+ "foundational",
155
+ "working",
156
+ "practitioner",
157
+ "expert",
158
+ ]);
159
+ const validMaturities = new Set([
160
+ "emerging",
161
+ "developing",
162
+ "practicing",
163
+ "role_modeling",
164
+ "exemplifying",
165
+ ]);
166
+
167
+ for (const sa of result.selfAssessments) {
168
+ for (const val of Object.values(sa.skillProficiencies)) {
169
+ assert.ok(validProficiencies.has(val), `Invalid proficiency: ${val}`);
170
+ }
171
+ for (const val of Object.values(sa.behaviourMaturities)) {
172
+ assert.ok(validMaturities.has(val), `Invalid maturity: ${val}`);
173
+ }
174
+ }
175
+ });
176
+ });
@@ -0,0 +1,249 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildFrameworkPrompt } from "../prompts/pathway/framework.js";
4
+ import { buildLevelPrompt } from "../prompts/pathway/level.js";
5
+ import { buildStagePrompt } from "../prompts/pathway/stage.js";
6
+ import { buildBehaviourPrompt } from "../prompts/pathway/behaviour.js";
7
+ import { buildCapabilityPrompt } from "../prompts/pathway/capability.js";
8
+ import { buildDriverPrompt } from "../prompts/pathway/driver.js";
9
+ import { buildDisciplinePrompt } from "../prompts/pathway/discipline.js";
10
+ import { buildTrackPrompt } from "../prompts/pathway/track.js";
11
+
12
+ const CTX = {
13
+ domain: "test.example",
14
+ industry: "pharma",
15
+ frameworkName: "Test Framework",
16
+ };
17
+
18
+ const SCHEMA = { type: "object", properties: {} };
19
+
20
+ describe("prompt builders", () => {
21
+ describe("buildFrameworkPrompt", () => {
22
+ test("returns system and user strings", () => {
23
+ const result = buildFrameworkPrompt({}, CTX, SCHEMA);
24
+ assert.ok(typeof result.system === "string");
25
+ assert.ok(typeof result.user === "string");
26
+ });
27
+
28
+ test("includes preamble in system prompt", () => {
29
+ const result = buildFrameworkPrompt({}, CTX, SCHEMA);
30
+ assert.ok(result.system.includes("Test Framework"));
31
+ });
32
+
33
+ test("includes JSON schema in user prompt", () => {
34
+ const result = buildFrameworkPrompt({}, CTX, SCHEMA);
35
+ assert.ok(result.user.includes('"type"'));
36
+ });
37
+ });
38
+
39
+ describe("buildLevelPrompt", () => {
40
+ const levels = [
41
+ { id: "J040", professionalTitle: "Engineer", rank: 1, experience: "0-2" },
42
+ {
43
+ id: "J050",
44
+ professionalTitle: "Senior",
45
+ rank: 2,
46
+ experience: "2-5",
47
+ },
48
+ ];
49
+
50
+ test("returns system and user strings", () => {
51
+ const result = buildLevelPrompt(levels, CTX, SCHEMA);
52
+ assert.ok(typeof result.system === "string");
53
+ assert.ok(typeof result.user === "string");
54
+ });
55
+
56
+ test("includes level IDs in user prompt", () => {
57
+ const result = buildLevelPrompt(levels, CTX, SCHEMA);
58
+ assert.ok(result.user.includes("J040"));
59
+ assert.ok(result.user.includes("J050"));
60
+ });
61
+
62
+ test("uses vocabulary imports for proficiency names", () => {
63
+ const result = buildLevelPrompt(levels, CTX, SCHEMA);
64
+ assert.ok(result.user.includes("awareness"));
65
+ assert.ok(result.user.includes("expert"));
66
+ });
67
+ });
68
+
69
+ describe("buildStagePrompt", () => {
70
+ const stageIds = ["specify", "plan", "code"];
71
+
72
+ test("returns system and user strings", () => {
73
+ const result = buildStagePrompt(stageIds, CTX, SCHEMA);
74
+ assert.ok(typeof result.system === "string");
75
+ assert.ok(typeof result.user === "string");
76
+ });
77
+
78
+ test("includes stage IDs in user prompt", () => {
79
+ const result = buildStagePrompt(stageIds, CTX, SCHEMA);
80
+ for (const id of stageIds) {
81
+ assert.ok(result.user.includes(id));
82
+ }
83
+ });
84
+
85
+ test("includes handoff constraint", () => {
86
+ const result = buildStagePrompt(stageIds, CTX, SCHEMA);
87
+ assert.ok(result.user.includes("MUST be one of the stage IDs"));
88
+ });
89
+ });
90
+
91
+ describe("buildBehaviourPrompt", () => {
92
+ const skeleton = { id: "collaboration", name: "Collaboration" };
93
+
94
+ test("returns system and user strings", () => {
95
+ const result = buildBehaviourPrompt(skeleton, CTX, SCHEMA);
96
+ assert.ok(typeof result.system === "string");
97
+ assert.ok(typeof result.user === "string");
98
+ });
99
+
100
+ test("includes behaviour ID", () => {
101
+ const result = buildBehaviourPrompt(skeleton, CTX, SCHEMA);
102
+ assert.ok(result.user.includes("collaboration"));
103
+ });
104
+
105
+ test("includes prior output when provided", () => {
106
+ const priorOutput = {
107
+ levels: [
108
+ {
109
+ id: "L1",
110
+ professionalTitle: "Engineer",
111
+ baseSkillProficiencies: { primary: "awareness" },
112
+ },
113
+ ],
114
+ };
115
+ const result = buildBehaviourPrompt(skeleton, CTX, SCHEMA, priorOutput);
116
+ assert.ok(result.user.includes("Previously generated context"));
117
+ assert.ok(result.user.includes("Engineer"));
118
+ });
119
+ });
120
+
121
+ describe("buildCapabilityPrompt", () => {
122
+ const skeleton = {
123
+ id: "coding",
124
+ name: "Coding",
125
+ skills: ["python", "java"],
126
+ ordinalRank: 1,
127
+ };
128
+
129
+ test("returns system and user strings", () => {
130
+ const result = buildCapabilityPrompt(skeleton, CTX, SCHEMA);
131
+ assert.ok(typeof result.system === "string");
132
+ assert.ok(typeof result.user === "string");
133
+ });
134
+
135
+ test("includes skill IDs", () => {
136
+ const result = buildCapabilityPrompt(skeleton, CTX, SCHEMA);
137
+ assert.ok(result.user.includes("python"));
138
+ assert.ok(result.user.includes("java"));
139
+ });
140
+
141
+ test("includes prior output when provided", () => {
142
+ const priorOutput = {
143
+ levels: [{ id: "L1", professionalTitle: "Junior" }],
144
+ behaviours: [{ _id: "collab", name: "Collaboration" }],
145
+ };
146
+ const result = buildCapabilityPrompt(skeleton, CTX, SCHEMA, priorOutput);
147
+ assert.ok(result.user.includes("Previously generated context"));
148
+ assert.ok(result.user.includes("Junior"));
149
+ assert.ok(result.user.includes("Collaboration"));
150
+ });
151
+ });
152
+
153
+ describe("buildDriverPrompt", () => {
154
+ const drivers = [
155
+ { id: "clear_direction", name: "Clear Direction", skills: ["python"] },
156
+ ];
157
+
158
+ test("returns system and user strings", () => {
159
+ const result = buildDriverPrompt(
160
+ drivers,
161
+ { ...CTX, skillIds: ["python"], behaviourIds: ["collab"] },
162
+ SCHEMA,
163
+ );
164
+ assert.ok(typeof result.system === "string");
165
+ assert.ok(typeof result.user === "string");
166
+ });
167
+
168
+ test("includes driver IDs", () => {
169
+ const result = buildDriverPrompt(
170
+ drivers,
171
+ { ...CTX, skillIds: ["python"], behaviourIds: [] },
172
+ SCHEMA,
173
+ );
174
+ assert.ok(result.user.includes("clear_direction"));
175
+ });
176
+ });
177
+
178
+ describe("buildDisciplinePrompt", () => {
179
+ const skeleton = {
180
+ id: "se",
181
+ roleTitle: "Software Engineer",
182
+ specialization: "Backend",
183
+ isProfessional: true,
184
+ core: ["python"],
185
+ supporting: [],
186
+ broad: [],
187
+ validTracks: [null],
188
+ };
189
+
190
+ test("returns system and user strings", () => {
191
+ const result = buildDisciplinePrompt(
192
+ skeleton,
193
+ { ...CTX, skillIds: ["python"], behaviourIds: [], trackIds: [] },
194
+ SCHEMA,
195
+ );
196
+ assert.ok(typeof result.system === "string");
197
+ assert.ok(typeof result.user === "string");
198
+ });
199
+
200
+ test("does not reference {roleName} placeholder", () => {
201
+ const result = buildDisciplinePrompt(
202
+ skeleton,
203
+ { ...CTX, skillIds: ["python"], behaviourIds: [], trackIds: [] },
204
+ SCHEMA,
205
+ );
206
+ assert.ok(!result.user.includes("{roleName}"));
207
+ });
208
+
209
+ test("includes prior output when provided", () => {
210
+ const priorOutput = {
211
+ levels: [{ id: "L1", professionalTitle: "Junior" }],
212
+ behaviours: [{ _id: "collab", name: "Collaboration" }],
213
+ capabilities: [
214
+ { _id: "coding", name: "Coding", skills: [{ id: "python" }] },
215
+ ],
216
+ };
217
+ const result = buildDisciplinePrompt(
218
+ skeleton,
219
+ { ...CTX, skillIds: ["python"], behaviourIds: [], trackIds: [] },
220
+ SCHEMA,
221
+ priorOutput,
222
+ );
223
+ assert.ok(result.user.includes("Previously generated context"));
224
+ });
225
+ });
226
+
227
+ describe("buildTrackPrompt", () => {
228
+ const skeleton = { id: "backend", name: "Backend" };
229
+
230
+ test("returns system and user strings", () => {
231
+ const result = buildTrackPrompt(
232
+ skeleton,
233
+ { ...CTX, capabilityIds: ["coding"], behaviourIds: ["collab"] },
234
+ SCHEMA,
235
+ );
236
+ assert.ok(typeof result.system === "string");
237
+ assert.ok(typeof result.user === "string");
238
+ });
239
+
240
+ test("includes track ID", () => {
241
+ const result = buildTrackPrompt(
242
+ skeleton,
243
+ { ...CTX, capabilityIds: [], behaviourIds: [] },
244
+ SCHEMA,
245
+ );
246
+ assert.ok(result.user.includes("backend"));
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync, rmSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+ import { ProseEngine } from "../engine/prose.js";
7
+
8
+ function makeLogger() {
9
+ return {
10
+ info: () => {},
11
+ debug: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ };
15
+ }
16
+
17
+ function makePromptLoader() {
18
+ return {
19
+ load: () => "system prompt",
20
+ render: () => "rendered prompt",
21
+ };
22
+ }
23
+
24
+ function makeLlmApi(response = "test response") {
25
+ return {
26
+ createCompletions: async () => ({
27
+ choices: [{ message: { content: response } }],
28
+ }),
29
+ };
30
+ }
31
+
32
+ describe("ProseEngine", () => {
33
+ test("returns null in no-prose mode", async () => {
34
+ const tmpDir = mkdtempSync(join(tmpdir(), "prose-test-"));
35
+ try {
36
+ const engine = new ProseEngine({
37
+ cachePath: join(tmpDir, "cache.json"),
38
+ mode: "no-prose",
39
+ promptLoader: makePromptLoader(),
40
+ logger: makeLogger(),
41
+ });
42
+ const result = await engine.generateProse("test-key", { topic: "test" });
43
+ assert.strictEqual(result, null);
44
+ } finally {
45
+ rmSync(tmpDir, { recursive: true });
46
+ }
47
+ });
48
+
49
+ test("returns cached result on cache hit", async () => {
50
+ const tmpDir = mkdtempSync(join(tmpdir(), "prose-test-"));
51
+ try {
52
+ const cacheData = {};
53
+ // Pre-populate cache with a known hash
54
+ const cachePath = join(tmpDir, "cache.json");
55
+ writeFileSync(cachePath, JSON.stringify(cacheData));
56
+
57
+ const engine = new ProseEngine({
58
+ cachePath,
59
+ mode: "generate",
60
+ llmApi: makeLlmApi("fresh response"),
61
+ promptLoader: makePromptLoader(),
62
+ logger: makeLogger(),
63
+ });
64
+
65
+ // Generate first
66
+ const r1 = await engine.generateProse("key1", { topic: "test" });
67
+ assert.strictEqual(r1, "fresh response");
68
+ assert.strictEqual(engine.stats.generated, 1);
69
+
70
+ // Save and reload
71
+ engine.saveCache();
72
+
73
+ const engine2 = new ProseEngine({
74
+ cachePath,
75
+ mode: "cached",
76
+ promptLoader: makePromptLoader(),
77
+ logger: makeLogger(),
78
+ });
79
+
80
+ const r2 = await engine2.generateProse("key1", { topic: "test" });
81
+ assert.strictEqual(r2, "fresh response");
82
+ assert.strictEqual(engine2.stats.hits, 1);
83
+ } finally {
84
+ rmSync(tmpDir, { recursive: true });
85
+ }
86
+ });
87
+
88
+ test("generateStructured respects maxTokens option", async () => {
89
+ const tmpDir = mkdtempSync(join(tmpdir(), "prose-test-"));
90
+ let capturedMaxTokens = null;
91
+ try {
92
+ const engine = new ProseEngine({
93
+ cachePath: join(tmpDir, "cache.json"),
94
+ mode: "generate",
95
+ llmApi: {
96
+ createCompletions: async (opts) => {
97
+ capturedMaxTokens = opts.max_tokens;
98
+ return {
99
+ choices: [{ message: { content: '{"test": true}' } }],
100
+ };
101
+ },
102
+ },
103
+ promptLoader: makePromptLoader(),
104
+ logger: makeLogger(),
105
+ });
106
+
107
+ await engine.generateStructured(
108
+ "test-key",
109
+ [{ role: "user", content: "test" }],
110
+ { maxTokens: 2000 },
111
+ );
112
+ assert.strictEqual(capturedMaxTokens, 2000);
113
+ } finally {
114
+ rmSync(tmpDir, { recursive: true });
115
+ }
116
+ });
117
+
118
+ test("generateJson strips markdown fences", async () => {
119
+ const tmpDir = mkdtempSync(join(tmpdir(), "prose-test-"));
120
+ try {
121
+ const engine = new ProseEngine({
122
+ cachePath: join(tmpDir, "cache.json"),
123
+ mode: "generate",
124
+ llmApi: makeLlmApi('```json\n{"key": "value"}\n```'),
125
+ promptLoader: makePromptLoader(),
126
+ logger: makeLogger(),
127
+ });
128
+
129
+ const result = await engine.generateJson("test-key", [
130
+ { role: "user", content: "test" },
131
+ ]);
132
+ assert.deepStrictEqual(result, { key: "value" });
133
+ } finally {
134
+ rmSync(tmpDir, { recursive: true });
135
+ }
136
+ });
137
+
138
+ test("generateJson returns null in no-prose mode", async () => {
139
+ const tmpDir = mkdtempSync(join(tmpdir(), "prose-test-"));
140
+ try {
141
+ const engine = new ProseEngine({
142
+ cachePath: join(tmpDir, "cache.json"),
143
+ mode: "no-prose",
144
+ promptLoader: makePromptLoader(),
145
+ logger: makeLogger(),
146
+ });
147
+
148
+ const result = await engine.generateJson("test-key", [
149
+ { role: "user", content: "test" },
150
+ ]);
151
+ assert.strictEqual(result, null);
152
+ } finally {
153
+ rmSync(tmpDir, { recursive: true });
154
+ }
155
+ });
156
+ });