@forwardimpact/model 0.5.0 → 0.7.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/README.md +2 -2
- package/package.json +17 -15
- package/{lib → src}/agent.js +25 -19
- package/{lib → src}/checklist.js +2 -10
- package/{lib → src}/derivation.js +17 -15
- package/{lib → src}/index.js +88 -17
- package/src/interview.js +1001 -0
- package/{lib → src}/job-cache.js +7 -7
- package/{lib → src}/matching.js +65 -41
- package/{lib → src}/modifiers.js +4 -4
- package/src/policies/composed.js +135 -0
- package/src/policies/filters.js +104 -0
- package/src/policies/index.js +160 -0
- package/src/policies/orderings.js +312 -0
- package/src/policies/predicates.js +177 -0
- package/src/policies/thresholds.js +317 -0
- package/src/profile.js +145 -0
- package/{lib → src}/progression.js +8 -13
- package/{lib → src}/toolkit.js +3 -3
- package/lib/interview.js +0 -539
- package/lib/profile.js +0 -262
- /package/{lib → src}/job.js +0 -0
package/src/interview.js
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engineering Pathway Interview Question Generation
|
|
3
|
+
*
|
|
4
|
+
* This module provides pure functions for generating interview questions
|
|
5
|
+
* based on job definitions and question banks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getSkillLevelIndex,
|
|
10
|
+
getBehaviourMaturityIndex,
|
|
11
|
+
SKILL_LEVEL_ORDER,
|
|
12
|
+
Capability,
|
|
13
|
+
} from "@forwardimpact/schema/levels";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
WEIGHT_SKILL_TYPE,
|
|
17
|
+
WEIGHT_CAPABILITY_BOOST,
|
|
18
|
+
WEIGHT_BEHAVIOUR_BASE,
|
|
19
|
+
WEIGHT_BEHAVIOUR_MATURITY,
|
|
20
|
+
WEIGHT_SKILL_LEVEL,
|
|
21
|
+
WEIGHT_BELOW_LEVEL_PENALTY,
|
|
22
|
+
RATIO_SKILL_BEHAVIOUR,
|
|
23
|
+
DEFAULT_INTERVIEW_QUESTION_MINUTES,
|
|
24
|
+
DEFAULT_DECOMPOSITION_QUESTION_MINUTES,
|
|
25
|
+
DEFAULT_SIMULATION_QUESTION_MINUTES,
|
|
26
|
+
TOLERANCE_INTERVIEW_BUDGET_MINUTES,
|
|
27
|
+
WEIGHT_CAPABILITY_DECOMP_DELIVERY,
|
|
28
|
+
WEIGHT_CAPABILITY_DECOMP_SCALE,
|
|
29
|
+
WEIGHT_CAPABILITY_DECOMP_RELIABILITY,
|
|
30
|
+
WEIGHT_FOCUS_BOOST,
|
|
31
|
+
} from "./policies/thresholds.js";
|
|
32
|
+
|
|
33
|
+
import { compareByMaturityDesc } from "./policies/orderings.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get questions from the question bank for a specific skill and level
|
|
37
|
+
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
38
|
+
* @param {string} skillId - The skill ID
|
|
39
|
+
* @param {string} level - The skill level
|
|
40
|
+
* @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
41
|
+
* @returns {import('./levels.js').Question[]} Array of questions
|
|
42
|
+
*/
|
|
43
|
+
function getSkillQuestions(
|
|
44
|
+
questionBank,
|
|
45
|
+
skillId,
|
|
46
|
+
level,
|
|
47
|
+
roleType = "professionalQuestions",
|
|
48
|
+
) {
|
|
49
|
+
return questionBank.skillLevels?.[skillId]?.[roleType]?.[level] || [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get questions from the question bank for a specific behaviour and maturity
|
|
54
|
+
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
55
|
+
* @param {string} behaviourId - The behaviour ID
|
|
56
|
+
* @param {string} maturity - The maturity level
|
|
57
|
+
* @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
58
|
+
* @returns {import('./levels.js').Question[]} Array of questions
|
|
59
|
+
*/
|
|
60
|
+
function getBehaviourQuestions(
|
|
61
|
+
questionBank,
|
|
62
|
+
behaviourId,
|
|
63
|
+
maturity,
|
|
64
|
+
roleType = "professionalQuestions",
|
|
65
|
+
) {
|
|
66
|
+
return (
|
|
67
|
+
questionBank.behaviourMaturities?.[behaviourId]?.[roleType]?.[maturity] ||
|
|
68
|
+
[]
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get decomposition questions from the question bank for a specific capability and level
|
|
74
|
+
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
75
|
+
* @param {string} capabilityId - The capability ID
|
|
76
|
+
* @param {string} level - The skill level (capabilities use same levels as skills)
|
|
77
|
+
* @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
78
|
+
* @returns {import('./levels.js').Question[]} Array of questions
|
|
79
|
+
*/
|
|
80
|
+
function getCapabilityQuestions(
|
|
81
|
+
questionBank,
|
|
82
|
+
capabilityId,
|
|
83
|
+
level,
|
|
84
|
+
roleType = "professionalQuestions",
|
|
85
|
+
) {
|
|
86
|
+
return (
|
|
87
|
+
questionBank.capabilityLevels?.[capabilityId]?.[roleType]?.[level] || []
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Derive capability levels from a job's skill matrix
|
|
93
|
+
* Uses the maximum skill level in each capability.
|
|
94
|
+
* @param {import('./levels.js').JobDefinition} job - The job definition
|
|
95
|
+
* @returns {Map<string, {capabilityId: string, level: string, levelIndex: number}>} Map of capability to level info
|
|
96
|
+
*/
|
|
97
|
+
function deriveCapabilityLevels(job) {
|
|
98
|
+
const capabilityLevels = new Map();
|
|
99
|
+
|
|
100
|
+
for (const skill of job.skillMatrix) {
|
|
101
|
+
const capabilityId = skill.capability;
|
|
102
|
+
const levelIndex = getSkillLevelIndex(skill.level);
|
|
103
|
+
|
|
104
|
+
const existing = capabilityLevels.get(capabilityId);
|
|
105
|
+
if (!existing || levelIndex > existing.levelIndex) {
|
|
106
|
+
capabilityLevels.set(capabilityId, {
|
|
107
|
+
capabilityId,
|
|
108
|
+
level: skill.level,
|
|
109
|
+
levelIndex,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return capabilityLevels;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Calculate priority for a skill question
|
|
119
|
+
* @param {import('./levels.js').SkillMatrixEntry} skill - The skill entry
|
|
120
|
+
* @param {boolean} includeBelowLevel - Whether this is a below-level question
|
|
121
|
+
* @returns {number} Priority score (higher = more important)
|
|
122
|
+
*/
|
|
123
|
+
function calculateSkillPriority(skill, includeBelowLevel = false) {
|
|
124
|
+
let priority = 0;
|
|
125
|
+
|
|
126
|
+
// Skill type priority from policy weights
|
|
127
|
+
priority += WEIGHT_SKILL_TYPE[skill.type] || WEIGHT_SKILL_TYPE.broad;
|
|
128
|
+
|
|
129
|
+
// AI skills get a boost for "AI-era focus"
|
|
130
|
+
if (skill.capability === Capability.AI) {
|
|
131
|
+
priority += WEIGHT_CAPABILITY_BOOST.ai;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Delivery skills get a core technical boost
|
|
135
|
+
if (skill.capability === Capability.DELIVERY) {
|
|
136
|
+
priority += WEIGHT_CAPABILITY_BOOST.delivery;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Higher skill level = higher priority
|
|
140
|
+
priority += getSkillLevelIndex(skill.level) * WEIGHT_SKILL_LEVEL;
|
|
141
|
+
|
|
142
|
+
// Below-level questions have lower priority
|
|
143
|
+
if (includeBelowLevel) {
|
|
144
|
+
priority += WEIGHT_BELOW_LEVEL_PENALTY;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return priority;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Calculate priority for a behaviour question
|
|
152
|
+
* @param {import('./levels.js').BehaviourProfileEntry} behaviour - The behaviour entry
|
|
153
|
+
* @returns {number} Priority score (higher = more important)
|
|
154
|
+
*/
|
|
155
|
+
function calculateBehaviourPriority(behaviour) {
|
|
156
|
+
let priority = WEIGHT_BEHAVIOUR_BASE;
|
|
157
|
+
|
|
158
|
+
// Higher maturity level = higher priority
|
|
159
|
+
priority +=
|
|
160
|
+
getBehaviourMaturityIndex(behaviour.maturity) * WEIGHT_BEHAVIOUR_MATURITY;
|
|
161
|
+
|
|
162
|
+
return priority;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Calculate priority for a capability decomposition question
|
|
167
|
+
* @param {string} capabilityId - The capability ID
|
|
168
|
+
* @param {number} levelIndex - The skill level index
|
|
169
|
+
* @returns {number} Priority score (higher = more important)
|
|
170
|
+
*/
|
|
171
|
+
function calculateCapabilityPriority(capabilityId, levelIndex) {
|
|
172
|
+
let priority = 0;
|
|
173
|
+
|
|
174
|
+
// Delivery and scale capabilities are typically more important for decomposition
|
|
175
|
+
if (capabilityId === Capability.DELIVERY) {
|
|
176
|
+
priority += WEIGHT_CAPABILITY_DECOMP_DELIVERY;
|
|
177
|
+
} else if (capabilityId === Capability.SCALE) {
|
|
178
|
+
priority += WEIGHT_CAPABILITY_DECOMP_SCALE;
|
|
179
|
+
} else if (capabilityId === Capability.RELIABILITY) {
|
|
180
|
+
priority += WEIGHT_CAPABILITY_DECOMP_RELIABILITY;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Higher level = higher priority
|
|
184
|
+
priority += levelIndex * WEIGHT_SKILL_LEVEL;
|
|
185
|
+
|
|
186
|
+
return priority;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Select a random question from an array (or first if deterministic)
|
|
191
|
+
* @param {import('./levels.js').Question[]} questions - Array of questions
|
|
192
|
+
* @param {boolean} deterministic - If true, always select first question
|
|
193
|
+
* @returns {import('./levels.js').Question|null} Selected question or null
|
|
194
|
+
*/
|
|
195
|
+
function selectQuestion(questions, deterministic = false) {
|
|
196
|
+
if (!questions || questions.length === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (deterministic) {
|
|
200
|
+
return questions[0];
|
|
201
|
+
}
|
|
202
|
+
return questions[Math.floor(Math.random() * questions.length)];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Derive interview questions for a job
|
|
207
|
+
* @param {Object} params
|
|
208
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
209
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
210
|
+
* @param {Object} [params.options] - Generation options
|
|
211
|
+
* @param {boolean} [params.options.includeBelowLevel=true] - Include one question from level below
|
|
212
|
+
* @param {boolean} [params.options.deterministic=false] - Use deterministic selection
|
|
213
|
+
* @param {number} [params.options.maxQuestionsPerSkill=2] - Max questions per skill
|
|
214
|
+
* @param {number} [params.options.maxQuestionsPerBehaviour=2] - Max questions per behaviour
|
|
215
|
+
* @param {number} [params.options.targetMinutes=60] - Target interview length in minutes
|
|
216
|
+
* @param {number} [params.options.skillBehaviourRatio=RATIO_SKILL_BEHAVIOUR] - Ratio of time for skills vs behaviours
|
|
217
|
+
* @param {string} [params.options.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
218
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
219
|
+
*/
|
|
220
|
+
export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
221
|
+
const {
|
|
222
|
+
includeBelowLevel = true,
|
|
223
|
+
deterministic = false,
|
|
224
|
+
maxQuestionsPerSkill = 2,
|
|
225
|
+
maxQuestionsPerBehaviour = 2,
|
|
226
|
+
targetMinutes = 60,
|
|
227
|
+
skillBehaviourRatio = RATIO_SKILL_BEHAVIOUR,
|
|
228
|
+
roleType = "professionalQuestions",
|
|
229
|
+
} = options;
|
|
230
|
+
|
|
231
|
+
const allSkillQuestions = [];
|
|
232
|
+
const allBehaviourQuestions = [];
|
|
233
|
+
const coveredSkills = new Set();
|
|
234
|
+
const coveredBehaviours = new Set();
|
|
235
|
+
|
|
236
|
+
// Generate all potential skill questions with priority
|
|
237
|
+
for (const skill of job.skillMatrix) {
|
|
238
|
+
const targetLevel = skill.level;
|
|
239
|
+
const targetLevelIndex = getSkillLevelIndex(targetLevel);
|
|
240
|
+
|
|
241
|
+
// Get questions at target level
|
|
242
|
+
const targetQuestions = getSkillQuestions(
|
|
243
|
+
questionBank,
|
|
244
|
+
skill.skillId,
|
|
245
|
+
targetLevel,
|
|
246
|
+
roleType,
|
|
247
|
+
);
|
|
248
|
+
let questionsAdded = 0;
|
|
249
|
+
|
|
250
|
+
// Add question(s) at target level
|
|
251
|
+
for (const question of targetQuestions) {
|
|
252
|
+
if (questionsAdded >= maxQuestionsPerSkill) break;
|
|
253
|
+
|
|
254
|
+
allSkillQuestions.push({
|
|
255
|
+
question,
|
|
256
|
+
targetId: skill.skillId,
|
|
257
|
+
targetName: skill.skillName,
|
|
258
|
+
targetType: "skill",
|
|
259
|
+
targetLevel,
|
|
260
|
+
priority: calculateSkillPriority(skill, false),
|
|
261
|
+
});
|
|
262
|
+
questionsAdded++;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Optionally add question from level below
|
|
266
|
+
if (
|
|
267
|
+
includeBelowLevel &&
|
|
268
|
+
targetLevelIndex > 0 &&
|
|
269
|
+
questionsAdded < maxQuestionsPerSkill
|
|
270
|
+
) {
|
|
271
|
+
const belowLevel = SKILL_LEVEL_ORDER[targetLevelIndex - 1];
|
|
272
|
+
const belowQuestions = getSkillQuestions(
|
|
273
|
+
questionBank,
|
|
274
|
+
skill.skillId,
|
|
275
|
+
belowLevel,
|
|
276
|
+
roleType,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const belowQuestion = selectQuestion(belowQuestions, deterministic);
|
|
280
|
+
if (belowQuestion) {
|
|
281
|
+
allSkillQuestions.push({
|
|
282
|
+
question: belowQuestion,
|
|
283
|
+
targetId: skill.skillId,
|
|
284
|
+
targetName: skill.skillName,
|
|
285
|
+
targetType: "skill",
|
|
286
|
+
targetLevel: belowLevel,
|
|
287
|
+
priority: calculateSkillPriority(skill, true),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Generate all potential behaviour questions with priority
|
|
294
|
+
for (const behaviour of job.behaviourProfile) {
|
|
295
|
+
const targetMaturity = behaviour.maturity;
|
|
296
|
+
const questions = getBehaviourQuestions(
|
|
297
|
+
questionBank,
|
|
298
|
+
behaviour.behaviourId,
|
|
299
|
+
targetMaturity,
|
|
300
|
+
roleType,
|
|
301
|
+
targetMaturity,
|
|
302
|
+
);
|
|
303
|
+
let questionsAdded = 0;
|
|
304
|
+
|
|
305
|
+
for (const question of questions) {
|
|
306
|
+
if (questionsAdded >= maxQuestionsPerBehaviour) break;
|
|
307
|
+
|
|
308
|
+
allBehaviourQuestions.push({
|
|
309
|
+
question,
|
|
310
|
+
targetId: behaviour.behaviourId,
|
|
311
|
+
targetName: behaviour.behaviourName,
|
|
312
|
+
targetType: "behaviour",
|
|
313
|
+
targetLevel: targetMaturity,
|
|
314
|
+
priority: calculateBehaviourPriority(behaviour),
|
|
315
|
+
});
|
|
316
|
+
questionsAdded++;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Sort both lists by priority (highest first)
|
|
321
|
+
allSkillQuestions.sort((a, b) => b.priority - a.priority);
|
|
322
|
+
allBehaviourQuestions.sort((a, b) => b.priority - a.priority);
|
|
323
|
+
|
|
324
|
+
// Calculate time budgets
|
|
325
|
+
const skillTimeBudget = targetMinutes * skillBehaviourRatio;
|
|
326
|
+
const behaviourTimeBudget = targetMinutes * (1 - skillBehaviourRatio);
|
|
327
|
+
|
|
328
|
+
// Select skill questions within budget, prioritizing coverage diversity
|
|
329
|
+
// First pass: one question per skill (highest priority first)
|
|
330
|
+
const selectedQuestions = [];
|
|
331
|
+
const selectedSkillIds = new Set();
|
|
332
|
+
let skillMinutes = 0;
|
|
333
|
+
|
|
334
|
+
for (const q of allSkillQuestions) {
|
|
335
|
+
if (selectedSkillIds.has(q.targetId)) continue; // Skip if we already have this skill
|
|
336
|
+
const questionTime =
|
|
337
|
+
q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
338
|
+
if (
|
|
339
|
+
skillMinutes + questionTime <=
|
|
340
|
+
skillTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
341
|
+
) {
|
|
342
|
+
selectedQuestions.push(q);
|
|
343
|
+
selectedSkillIds.add(q.targetId);
|
|
344
|
+
coveredSkills.add(q.targetId);
|
|
345
|
+
skillMinutes += questionTime;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Second pass: add more questions if time allows
|
|
350
|
+
for (const q of allSkillQuestions) {
|
|
351
|
+
if (selectedQuestions.includes(q)) continue; // Skip already selected
|
|
352
|
+
const questionTime =
|
|
353
|
+
q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
354
|
+
if (
|
|
355
|
+
skillMinutes + questionTime <=
|
|
356
|
+
skillTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
357
|
+
) {
|
|
358
|
+
selectedQuestions.push(q);
|
|
359
|
+
coveredSkills.add(q.targetId);
|
|
360
|
+
skillMinutes += questionTime;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Select behaviour questions within budget, prioritizing coverage diversity
|
|
365
|
+
// First pass: one question per behaviour (highest priority first)
|
|
366
|
+
const selectedBehaviourIds = new Set();
|
|
367
|
+
let behaviourMinutes = 0;
|
|
368
|
+
|
|
369
|
+
for (const q of allBehaviourQuestions) {
|
|
370
|
+
if (selectedBehaviourIds.has(q.targetId)) continue; // Skip if we already have this behaviour
|
|
371
|
+
const questionTime =
|
|
372
|
+
q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
373
|
+
if (
|
|
374
|
+
behaviourMinutes + questionTime <=
|
|
375
|
+
behaviourTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
376
|
+
) {
|
|
377
|
+
selectedQuestions.push(q);
|
|
378
|
+
selectedBehaviourIds.add(q.targetId);
|
|
379
|
+
coveredBehaviours.add(q.targetId);
|
|
380
|
+
behaviourMinutes += questionTime;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Second pass: add more behaviour questions if time allows
|
|
385
|
+
for (const q of allBehaviourQuestions) {
|
|
386
|
+
if (selectedQuestions.includes(q)) continue; // Skip already selected
|
|
387
|
+
const questionTime =
|
|
388
|
+
q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
389
|
+
if (
|
|
390
|
+
behaviourMinutes + questionTime <=
|
|
391
|
+
behaviourTimeBudget + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
392
|
+
) {
|
|
393
|
+
selectedQuestions.push(q);
|
|
394
|
+
coveredBehaviours.add(q.targetId);
|
|
395
|
+
behaviourMinutes += questionTime;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Re-sort selected questions by priority
|
|
400
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
401
|
+
|
|
402
|
+
// Calculate total time
|
|
403
|
+
const expectedDurationMinutes = selectedQuestions.reduce(
|
|
404
|
+
(sum, q) =>
|
|
405
|
+
sum +
|
|
406
|
+
(q.question.expectedDurationMinutes ||
|
|
407
|
+
DEFAULT_INTERVIEW_QUESTION_MINUTES),
|
|
408
|
+
0,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
job,
|
|
413
|
+
questions: selectedQuestions,
|
|
414
|
+
expectedDurationMinutes,
|
|
415
|
+
coverage: {
|
|
416
|
+
skills: Array.from(coveredSkills),
|
|
417
|
+
behaviours: Array.from(coveredBehaviours),
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Derive a short/screening interview within a time budget
|
|
424
|
+
* @param {Object} params
|
|
425
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
426
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
427
|
+
* @param {number} [params.targetMinutes=20] - Target interview length in minutes
|
|
428
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
429
|
+
*/
|
|
430
|
+
export function deriveShortInterview({
|
|
431
|
+
job,
|
|
432
|
+
questionBank,
|
|
433
|
+
targetMinutes = 20,
|
|
434
|
+
}) {
|
|
435
|
+
// First get all potential questions with priority
|
|
436
|
+
const fullInterview = deriveInterviewQuestions({
|
|
437
|
+
job,
|
|
438
|
+
questionBank,
|
|
439
|
+
options: {
|
|
440
|
+
includeBelowLevel: false, // Skip below-level for short interviews
|
|
441
|
+
maxQuestionsPerSkill: 1,
|
|
442
|
+
maxQuestionsPerBehaviour: 1,
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Select questions until we hit the time budget
|
|
447
|
+
const selectedQuestions = [];
|
|
448
|
+
let totalMinutes = 0;
|
|
449
|
+
const coveredSkills = new Set();
|
|
450
|
+
const coveredBehaviours = new Set();
|
|
451
|
+
|
|
452
|
+
// Ensure we have at least some skill and behaviour coverage
|
|
453
|
+
// by alternating between skill and behaviour questions
|
|
454
|
+
const skillQuestions = fullInterview.questions.filter(
|
|
455
|
+
(q) => q.targetType === "skill",
|
|
456
|
+
);
|
|
457
|
+
const behaviourQuestions = fullInterview.questions.filter(
|
|
458
|
+
(q) => q.targetType === "behaviour",
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
let skillIndex = 0;
|
|
462
|
+
let behaviourIndex = 0;
|
|
463
|
+
let preferSkill = true;
|
|
464
|
+
|
|
465
|
+
while (totalMinutes < targetMinutes) {
|
|
466
|
+
let nextQuestion = null;
|
|
467
|
+
|
|
468
|
+
if (preferSkill && skillIndex < skillQuestions.length) {
|
|
469
|
+
nextQuestion = skillQuestions[skillIndex++];
|
|
470
|
+
} else if (!preferSkill && behaviourIndex < behaviourQuestions.length) {
|
|
471
|
+
nextQuestion = behaviourQuestions[behaviourIndex++];
|
|
472
|
+
} else if (skillIndex < skillQuestions.length) {
|
|
473
|
+
nextQuestion = skillQuestions[skillIndex++];
|
|
474
|
+
} else if (behaviourIndex < behaviourQuestions.length) {
|
|
475
|
+
nextQuestion = behaviourQuestions[behaviourIndex++];
|
|
476
|
+
} else {
|
|
477
|
+
break; // No more questions
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const questionTime =
|
|
481
|
+
nextQuestion.question.expectedDurationMinutes ||
|
|
482
|
+
DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
483
|
+
|
|
484
|
+
// Don't exceed budget by too much
|
|
485
|
+
if (
|
|
486
|
+
totalMinutes + questionTime >
|
|
487
|
+
targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
488
|
+
) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
selectedQuestions.push(nextQuestion);
|
|
493
|
+
totalMinutes += questionTime;
|
|
494
|
+
|
|
495
|
+
if (nextQuestion.targetType === "skill") {
|
|
496
|
+
coveredSkills.add(nextQuestion.targetId);
|
|
497
|
+
} else {
|
|
498
|
+
coveredBehaviours.add(nextQuestion.targetId);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
preferSkill = !preferSkill;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Re-sort selected questions by priority
|
|
505
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
job,
|
|
509
|
+
questions: selectedQuestions,
|
|
510
|
+
expectedDurationMinutes: totalMinutes,
|
|
511
|
+
coverage: {
|
|
512
|
+
skills: Array.from(coveredSkills),
|
|
513
|
+
behaviours: Array.from(coveredBehaviours),
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Derive behaviour-focused interview questions
|
|
520
|
+
* @param {Object} params
|
|
521
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
522
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
523
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
524
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
525
|
+
*/
|
|
526
|
+
export function deriveBehaviourQuestions({
|
|
527
|
+
job,
|
|
528
|
+
questionBank,
|
|
529
|
+
roleType = "professionalQuestions",
|
|
530
|
+
}) {
|
|
531
|
+
const interviewQuestions = [];
|
|
532
|
+
const coveredBehaviours = new Set();
|
|
533
|
+
|
|
534
|
+
// Focus only on behaviours, with more depth
|
|
535
|
+
for (const behaviour of job.behaviourProfile) {
|
|
536
|
+
const targetMaturity = behaviour.maturity;
|
|
537
|
+
|
|
538
|
+
// Get questions at target maturity
|
|
539
|
+
const targetQuestions = getBehaviourQuestions(
|
|
540
|
+
questionBank,
|
|
541
|
+
behaviour.behaviourId,
|
|
542
|
+
targetMaturity,
|
|
543
|
+
roleType,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
for (const question of targetQuestions) {
|
|
547
|
+
interviewQuestions.push({
|
|
548
|
+
question,
|
|
549
|
+
targetId: behaviour.behaviourId,
|
|
550
|
+
targetName: behaviour.behaviourName,
|
|
551
|
+
targetType: "behaviour",
|
|
552
|
+
targetLevel: targetMaturity,
|
|
553
|
+
priority: calculateBehaviourPriority(behaviour),
|
|
554
|
+
});
|
|
555
|
+
coveredBehaviours.add(behaviour.behaviourId);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Sort by priority
|
|
560
|
+
interviewQuestions.sort((a, b) => b.priority - a.priority);
|
|
561
|
+
|
|
562
|
+
// Calculate total time
|
|
563
|
+
const expectedDurationMinutes = interviewQuestions.reduce(
|
|
564
|
+
(sum, q) =>
|
|
565
|
+
sum +
|
|
566
|
+
(q.question.expectedDurationMinutes ||
|
|
567
|
+
DEFAULT_INTERVIEW_QUESTION_MINUTES),
|
|
568
|
+
0,
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
job,
|
|
573
|
+
questions: interviewQuestions,
|
|
574
|
+
expectedDurationMinutes,
|
|
575
|
+
coverage: {
|
|
576
|
+
skills: [],
|
|
577
|
+
behaviours: Array.from(coveredBehaviours),
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Generate a focused interview for specific skills/behaviours
|
|
584
|
+
* @param {Object} params
|
|
585
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
586
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
587
|
+
* @param {string[]} [params.focusSkills] - Skill IDs to focus on
|
|
588
|
+
* @param {string[]} [params.focusBehaviours] - Behaviour IDs to focus on
|
|
589
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
590
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
591
|
+
*/
|
|
592
|
+
export function deriveFocusedInterview({
|
|
593
|
+
job,
|
|
594
|
+
questionBank,
|
|
595
|
+
focusSkills = [],
|
|
596
|
+
focusBehaviours = [],
|
|
597
|
+
roleType = "professionalQuestions",
|
|
598
|
+
}) {
|
|
599
|
+
const interviewQuestions = [];
|
|
600
|
+
const coveredSkills = new Set();
|
|
601
|
+
const coveredBehaviours = new Set();
|
|
602
|
+
|
|
603
|
+
// Focus skills
|
|
604
|
+
const focusSkillSet = new Set(focusSkills);
|
|
605
|
+
for (const skill of job.skillMatrix) {
|
|
606
|
+
if (!focusSkillSet.has(skill.skillId)) continue;
|
|
607
|
+
|
|
608
|
+
const questions = getSkillQuestions(
|
|
609
|
+
questionBank,
|
|
610
|
+
skill.skillId,
|
|
611
|
+
skill.level,
|
|
612
|
+
roleType,
|
|
613
|
+
);
|
|
614
|
+
for (const question of questions) {
|
|
615
|
+
interviewQuestions.push({
|
|
616
|
+
question,
|
|
617
|
+
targetId: skill.skillId,
|
|
618
|
+
targetName: skill.skillName,
|
|
619
|
+
targetType: "skill",
|
|
620
|
+
targetLevel: skill.level,
|
|
621
|
+
priority: calculateSkillPriority(skill) + WEIGHT_FOCUS_BOOST,
|
|
622
|
+
});
|
|
623
|
+
coveredSkills.add(skill.skillId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Focus behaviours
|
|
628
|
+
const focusBehaviourSet = new Set(focusBehaviours);
|
|
629
|
+
for (const behaviour of job.behaviourProfile) {
|
|
630
|
+
if (!focusBehaviourSet.has(behaviour.behaviourId)) continue;
|
|
631
|
+
|
|
632
|
+
const questions = getBehaviourQuestions(
|
|
633
|
+
questionBank,
|
|
634
|
+
behaviour.behaviourId,
|
|
635
|
+
behaviour.maturity,
|
|
636
|
+
roleType,
|
|
637
|
+
);
|
|
638
|
+
for (const question of questions) {
|
|
639
|
+
interviewQuestions.push({
|
|
640
|
+
question,
|
|
641
|
+
targetId: behaviour.behaviourId,
|
|
642
|
+
targetName: behaviour.behaviourName,
|
|
643
|
+
targetType: "behaviour",
|
|
644
|
+
targetLevel: behaviour.maturity,
|
|
645
|
+
priority: calculateBehaviourPriority(behaviour) + WEIGHT_FOCUS_BOOST,
|
|
646
|
+
});
|
|
647
|
+
coveredBehaviours.add(behaviour.behaviourId);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Sort by priority
|
|
652
|
+
interviewQuestions.sort((a, b) => b.priority - a.priority);
|
|
653
|
+
|
|
654
|
+
const expectedDurationMinutes = interviewQuestions.reduce(
|
|
655
|
+
(sum, q) =>
|
|
656
|
+
sum +
|
|
657
|
+
(q.question.expectedDurationMinutes ||
|
|
658
|
+
DEFAULT_INTERVIEW_QUESTION_MINUTES),
|
|
659
|
+
0,
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
job,
|
|
664
|
+
questions: interviewQuestions,
|
|
665
|
+
expectedDurationMinutes,
|
|
666
|
+
coverage: {
|
|
667
|
+
skills: Array.from(coveredSkills),
|
|
668
|
+
behaviours: Array.from(coveredBehaviours),
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Derive Mission Fit interview questions (skill-focused)
|
|
675
|
+
*
|
|
676
|
+
* 45-minute interview with Recruiting Manager + 1 Senior Engineer
|
|
677
|
+
* Focuses on skill questions to assess technical capability and fit.
|
|
678
|
+
*
|
|
679
|
+
* @param {Object} params
|
|
680
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
681
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
682
|
+
* @param {number} [params.targetMinutes=45] - Target interview length in minutes
|
|
683
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
684
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
685
|
+
*/
|
|
686
|
+
export function deriveMissionFitInterview({
|
|
687
|
+
job,
|
|
688
|
+
questionBank,
|
|
689
|
+
targetMinutes = 45,
|
|
690
|
+
roleType = "professionalQuestions",
|
|
691
|
+
}) {
|
|
692
|
+
const allSkillQuestions = [];
|
|
693
|
+
const coveredSkills = new Set();
|
|
694
|
+
|
|
695
|
+
// Generate all potential skill questions with priority
|
|
696
|
+
for (const skill of job.skillMatrix) {
|
|
697
|
+
const targetLevel = skill.level;
|
|
698
|
+
const targetLevelIndex = getSkillLevelIndex(targetLevel);
|
|
699
|
+
|
|
700
|
+
// Get questions at target level
|
|
701
|
+
const targetQuestions = getSkillQuestions(
|
|
702
|
+
questionBank,
|
|
703
|
+
skill.skillId,
|
|
704
|
+
targetLevel,
|
|
705
|
+
roleType,
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
for (const question of targetQuestions) {
|
|
709
|
+
allSkillQuestions.push({
|
|
710
|
+
question,
|
|
711
|
+
targetId: skill.skillId,
|
|
712
|
+
targetName: skill.skillName,
|
|
713
|
+
targetType: "skill",
|
|
714
|
+
targetLevel,
|
|
715
|
+
priority: calculateSkillPriority(skill, false),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Also add question from level below for depth
|
|
720
|
+
if (targetLevelIndex > 0) {
|
|
721
|
+
const belowLevel = SKILL_LEVEL_ORDER[targetLevelIndex - 1];
|
|
722
|
+
const belowQuestions = getSkillQuestions(
|
|
723
|
+
questionBank,
|
|
724
|
+
skill.skillId,
|
|
725
|
+
belowLevel,
|
|
726
|
+
roleType,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
for (const question of belowQuestions) {
|
|
730
|
+
allSkillQuestions.push({
|
|
731
|
+
question,
|
|
732
|
+
targetId: skill.skillId,
|
|
733
|
+
targetName: skill.skillName,
|
|
734
|
+
targetType: "skill",
|
|
735
|
+
targetLevel: belowLevel,
|
|
736
|
+
priority: calculateSkillPriority(skill, true),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Sort by priority (highest first)
|
|
743
|
+
allSkillQuestions.sort((a, b) => b.priority - a.priority);
|
|
744
|
+
|
|
745
|
+
// Select questions within budget, prioritizing coverage diversity
|
|
746
|
+
const selectedQuestions = [];
|
|
747
|
+
const selectedSkillIds = new Set();
|
|
748
|
+
let totalMinutes = 0;
|
|
749
|
+
|
|
750
|
+
// First pass: one question per skill (highest priority first)
|
|
751
|
+
for (const q of allSkillQuestions) {
|
|
752
|
+
if (selectedSkillIds.has(q.targetId)) continue;
|
|
753
|
+
const questionTime =
|
|
754
|
+
q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
755
|
+
if (
|
|
756
|
+
totalMinutes + questionTime <=
|
|
757
|
+
targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
758
|
+
) {
|
|
759
|
+
selectedQuestions.push(q);
|
|
760
|
+
selectedSkillIds.add(q.targetId);
|
|
761
|
+
coveredSkills.add(q.targetId);
|
|
762
|
+
totalMinutes += questionTime;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Second pass: add more questions if time allows
|
|
767
|
+
for (const q of allSkillQuestions) {
|
|
768
|
+
if (selectedQuestions.includes(q)) continue;
|
|
769
|
+
const questionTime =
|
|
770
|
+
q.question.expectedDurationMinutes || DEFAULT_INTERVIEW_QUESTION_MINUTES;
|
|
771
|
+
if (
|
|
772
|
+
totalMinutes + questionTime <=
|
|
773
|
+
targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
774
|
+
) {
|
|
775
|
+
selectedQuestions.push(q);
|
|
776
|
+
coveredSkills.add(q.targetId);
|
|
777
|
+
totalMinutes += questionTime;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Re-sort by priority
|
|
782
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
job,
|
|
786
|
+
questions: selectedQuestions,
|
|
787
|
+
expectedDurationMinutes: totalMinutes,
|
|
788
|
+
coverage: {
|
|
789
|
+
skills: Array.from(coveredSkills),
|
|
790
|
+
behaviours: [],
|
|
791
|
+
capabilities: [],
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Derive Decomposition interview questions (capability-focused)
|
|
798
|
+
*
|
|
799
|
+
* 60-minute interview with 2 Senior Engineers
|
|
800
|
+
* Focuses on capability decomposition questions inspired by Palantir's technique.
|
|
801
|
+
* Capabilities are selected based on the job's skill matrix.
|
|
802
|
+
*
|
|
803
|
+
* @param {Object} params
|
|
804
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
805
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
806
|
+
* @param {number} [params.targetMinutes=60] - Target interview length in minutes
|
|
807
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
808
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
809
|
+
*/
|
|
810
|
+
export function deriveDecompositionInterview({
|
|
811
|
+
job,
|
|
812
|
+
questionBank,
|
|
813
|
+
targetMinutes = 60,
|
|
814
|
+
roleType = "professionalQuestions",
|
|
815
|
+
}) {
|
|
816
|
+
const allCapabilityQuestions = [];
|
|
817
|
+
const coveredCapabilities = new Set();
|
|
818
|
+
|
|
819
|
+
// Derive capability levels from the job's skill matrix
|
|
820
|
+
const capabilityLevels = deriveCapabilityLevels(job);
|
|
821
|
+
|
|
822
|
+
// Generate capability questions based on derived levels
|
|
823
|
+
for (const [capabilityId, levelInfo] of capabilityLevels) {
|
|
824
|
+
const { level, levelIndex } = levelInfo;
|
|
825
|
+
|
|
826
|
+
// Get questions at the derived level
|
|
827
|
+
const questions = getCapabilityQuestions(
|
|
828
|
+
questionBank,
|
|
829
|
+
capabilityId,
|
|
830
|
+
level,
|
|
831
|
+
roleType,
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
for (const question of questions) {
|
|
835
|
+
allCapabilityQuestions.push({
|
|
836
|
+
question,
|
|
837
|
+
targetId: capabilityId,
|
|
838
|
+
targetName: capabilityId, // Capability name can be enhanced if needed
|
|
839
|
+
targetType: "capability",
|
|
840
|
+
targetLevel: level,
|
|
841
|
+
priority: calculateCapabilityPriority(capabilityId, levelIndex),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Also try level below if available
|
|
846
|
+
if (levelIndex > 0) {
|
|
847
|
+
const belowLevel = SKILL_LEVEL_ORDER[levelIndex - 1];
|
|
848
|
+
const belowQuestions = getCapabilityQuestions(
|
|
849
|
+
questionBank,
|
|
850
|
+
capabilityId,
|
|
851
|
+
belowLevel,
|
|
852
|
+
roleType,
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
for (const question of belowQuestions) {
|
|
856
|
+
allCapabilityQuestions.push({
|
|
857
|
+
question,
|
|
858
|
+
targetId: capabilityId,
|
|
859
|
+
targetName: capabilityId,
|
|
860
|
+
targetType: "capability",
|
|
861
|
+
targetLevel: belowLevel,
|
|
862
|
+
priority: calculateCapabilityPriority(capabilityId, levelIndex - 1),
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Sort by priority (highest first)
|
|
869
|
+
allCapabilityQuestions.sort((a, b) => b.priority - a.priority);
|
|
870
|
+
|
|
871
|
+
// Select questions within budget, prioritizing coverage diversity
|
|
872
|
+
const selectedQuestions = [];
|
|
873
|
+
const selectedCapabilityIds = new Set();
|
|
874
|
+
let totalMinutes = 0;
|
|
875
|
+
|
|
876
|
+
// First pass: one question per capability (highest priority first)
|
|
877
|
+
for (const q of allCapabilityQuestions) {
|
|
878
|
+
if (selectedCapabilityIds.has(q.targetId)) continue;
|
|
879
|
+
const questionTime =
|
|
880
|
+
q.question.expectedDurationMinutes ||
|
|
881
|
+
DEFAULT_DECOMPOSITION_QUESTION_MINUTES;
|
|
882
|
+
if (
|
|
883
|
+
totalMinutes + questionTime <=
|
|
884
|
+
targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
885
|
+
) {
|
|
886
|
+
selectedQuestions.push(q);
|
|
887
|
+
selectedCapabilityIds.add(q.targetId);
|
|
888
|
+
coveredCapabilities.add(q.targetId);
|
|
889
|
+
totalMinutes += questionTime;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Second pass: add more questions if time allows
|
|
894
|
+
for (const q of allCapabilityQuestions) {
|
|
895
|
+
if (selectedQuestions.includes(q)) continue;
|
|
896
|
+
const questionTime =
|
|
897
|
+
q.question.expectedDurationMinutes ||
|
|
898
|
+
DEFAULT_DECOMPOSITION_QUESTION_MINUTES;
|
|
899
|
+
if (
|
|
900
|
+
totalMinutes + questionTime <=
|
|
901
|
+
targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
902
|
+
) {
|
|
903
|
+
selectedQuestions.push(q);
|
|
904
|
+
coveredCapabilities.add(q.targetId);
|
|
905
|
+
totalMinutes += questionTime;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Re-sort by priority
|
|
910
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
job,
|
|
914
|
+
questions: selectedQuestions,
|
|
915
|
+
expectedDurationMinutes: totalMinutes,
|
|
916
|
+
coverage: {
|
|
917
|
+
skills: [],
|
|
918
|
+
behaviours: [],
|
|
919
|
+
capabilities: Array.from(coveredCapabilities),
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Derive Stakeholder Simulation interview questions (behaviour-focused)
|
|
926
|
+
*
|
|
927
|
+
* 60-minute interview with 3-4 stakeholders.
|
|
928
|
+
* Selects the highest-maturity behaviours for the role and picks one chunky
|
|
929
|
+
* simulation question per behaviour. For most jobs this means 2-3 behaviours
|
|
930
|
+
* with ~20-minute simulation scenarios each.
|
|
931
|
+
*
|
|
932
|
+
* @param {Object} params
|
|
933
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
934
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
935
|
+
* @param {number} [params.targetMinutes=60] - Target interview length in minutes
|
|
936
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
937
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
938
|
+
*/
|
|
939
|
+
export function deriveStakeholderInterview({
|
|
940
|
+
job,
|
|
941
|
+
questionBank,
|
|
942
|
+
targetMinutes = 60,
|
|
943
|
+
roleType = "professionalQuestions",
|
|
944
|
+
}) {
|
|
945
|
+
const coveredBehaviours = new Set();
|
|
946
|
+
|
|
947
|
+
// Sort behaviours by maturity (highest first) to prioritize the most emphasized
|
|
948
|
+
const sortedBehaviours = [...job.behaviourProfile].sort(
|
|
949
|
+
compareByMaturityDesc,
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
// Select one question per behaviour, highest maturity first, within budget
|
|
953
|
+
const selectedQuestions = [];
|
|
954
|
+
let totalMinutes = 0;
|
|
955
|
+
|
|
956
|
+
for (const behaviour of sortedBehaviours) {
|
|
957
|
+
const targetMaturity = behaviour.maturity;
|
|
958
|
+
|
|
959
|
+
// Get questions at target maturity
|
|
960
|
+
const questions = getBehaviourQuestions(
|
|
961
|
+
questionBank,
|
|
962
|
+
behaviour.behaviourId,
|
|
963
|
+
targetMaturity,
|
|
964
|
+
roleType,
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
// Pick the first available question (1 per behaviour)
|
|
968
|
+
const question = questions[0];
|
|
969
|
+
if (!question) continue;
|
|
970
|
+
|
|
971
|
+
const questionTime =
|
|
972
|
+
question.expectedDurationMinutes || DEFAULT_SIMULATION_QUESTION_MINUTES;
|
|
973
|
+
if (
|
|
974
|
+
totalMinutes + questionTime >
|
|
975
|
+
targetMinutes + TOLERANCE_INTERVIEW_BUDGET_MINUTES
|
|
976
|
+
)
|
|
977
|
+
break;
|
|
978
|
+
|
|
979
|
+
selectedQuestions.push({
|
|
980
|
+
question,
|
|
981
|
+
targetId: behaviour.behaviourId,
|
|
982
|
+
targetName: behaviour.behaviourName,
|
|
983
|
+
targetType: "behaviour",
|
|
984
|
+
targetLevel: targetMaturity,
|
|
985
|
+
priority: calculateBehaviourPriority(behaviour),
|
|
986
|
+
});
|
|
987
|
+
coveredBehaviours.add(behaviour.behaviourId);
|
|
988
|
+
totalMinutes += questionTime;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return {
|
|
992
|
+
job,
|
|
993
|
+
questions: selectedQuestions,
|
|
994
|
+
expectedDurationMinutes: totalMinutes,
|
|
995
|
+
coverage: {
|
|
996
|
+
skills: [],
|
|
997
|
+
behaviours: Array.from(coveredBehaviours),
|
|
998
|
+
capabilities: [],
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
}
|