@forwardimpact/model 0.1.0
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/lib/agent.js +754 -0
- package/lib/checklist.js +103 -0
- package/lib/derivation.js +766 -0
- package/lib/index.js +121 -0
- package/lib/interview.js +539 -0
- package/lib/job-cache.js +89 -0
- package/lib/job.js +228 -0
- package/lib/matching.js +891 -0
- package/lib/modifiers.js +158 -0
- package/lib/profile.js +262 -0
- package/lib/progression.js +510 -0
- package/package.json +35 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @forwardimpact/model
|
|
3
|
+
*
|
|
4
|
+
* Pure business logic for Engineering Pathway framework.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Core derivation
|
|
8
|
+
export {
|
|
9
|
+
buildSkillTypeMap,
|
|
10
|
+
getSkillTypeForDiscipline,
|
|
11
|
+
findMaxBaseSkillLevel,
|
|
12
|
+
deriveSkillLevel,
|
|
13
|
+
deriveBehaviourMaturity,
|
|
14
|
+
deriveSkillMatrix,
|
|
15
|
+
deriveBehaviourProfile,
|
|
16
|
+
isValidJobCombination,
|
|
17
|
+
generateJobTitle,
|
|
18
|
+
deriveResponsibilities,
|
|
19
|
+
deriveJob,
|
|
20
|
+
calculateDriverCoverage,
|
|
21
|
+
getDisciplineSkillIds,
|
|
22
|
+
getGradeLevel,
|
|
23
|
+
isSeniorGrade,
|
|
24
|
+
generateAllJobs,
|
|
25
|
+
} from "./derivation.js";
|
|
26
|
+
|
|
27
|
+
// Job operations
|
|
28
|
+
export {
|
|
29
|
+
prepareJobDetail,
|
|
30
|
+
prepareJobSummary,
|
|
31
|
+
prepareJobBuilderPreview,
|
|
32
|
+
} from "./job.js";
|
|
33
|
+
|
|
34
|
+
// Job caching
|
|
35
|
+
export {
|
|
36
|
+
makeJobKey,
|
|
37
|
+
getOrCreateJob,
|
|
38
|
+
clearJobCache,
|
|
39
|
+
invalidateJob,
|
|
40
|
+
getCacheSize,
|
|
41
|
+
} from "./job-cache.js";
|
|
42
|
+
|
|
43
|
+
// Modifiers
|
|
44
|
+
export {
|
|
45
|
+
isCapability,
|
|
46
|
+
getSkillsByCapability,
|
|
47
|
+
buildCapabilityToSkillsMap,
|
|
48
|
+
expandSkillModifiers,
|
|
49
|
+
extractCapabilityModifiers,
|
|
50
|
+
extractIndividualModifiers,
|
|
51
|
+
resolveSkillModifier,
|
|
52
|
+
} from "./modifiers.js";
|
|
53
|
+
|
|
54
|
+
// Matching
|
|
55
|
+
export {
|
|
56
|
+
MatchTier,
|
|
57
|
+
MATCH_TIER_CONFIG,
|
|
58
|
+
classifyMatchTier,
|
|
59
|
+
GAP_SCORES,
|
|
60
|
+
calculateGapScore,
|
|
61
|
+
calculateJobMatch,
|
|
62
|
+
findMatchingJobs,
|
|
63
|
+
estimateBestFitGrade,
|
|
64
|
+
findRealisticMatches,
|
|
65
|
+
deriveDevelopmentPath,
|
|
66
|
+
findNextStepJob,
|
|
67
|
+
analyzeCandidate,
|
|
68
|
+
} from "./matching.js";
|
|
69
|
+
|
|
70
|
+
// Progression
|
|
71
|
+
export {
|
|
72
|
+
calculateSkillChanges,
|
|
73
|
+
calculateBehaviourChanges,
|
|
74
|
+
analyzeProgression,
|
|
75
|
+
analyzeGradeProgression,
|
|
76
|
+
analyzeTrackComparison,
|
|
77
|
+
getValidTracksForComparison,
|
|
78
|
+
getNextGrade,
|
|
79
|
+
getPreviousGrade,
|
|
80
|
+
analyzeCustomProgression,
|
|
81
|
+
getValidGradeTrackCombinations,
|
|
82
|
+
} from "./progression.js";
|
|
83
|
+
|
|
84
|
+
// Interview
|
|
85
|
+
export {
|
|
86
|
+
deriveInterviewQuestions,
|
|
87
|
+
deriveShortInterview,
|
|
88
|
+
deriveBehaviourQuestions,
|
|
89
|
+
deriveFocusedInterview,
|
|
90
|
+
} from "./interview.js";
|
|
91
|
+
|
|
92
|
+
// Agent generation
|
|
93
|
+
export {
|
|
94
|
+
deriveReferenceGrade,
|
|
95
|
+
getDisciplineAbbreviation,
|
|
96
|
+
toKebabCase,
|
|
97
|
+
deriveAgentSkills,
|
|
98
|
+
deriveAgentBehaviours,
|
|
99
|
+
generateSkillMd,
|
|
100
|
+
validateAgentProfile,
|
|
101
|
+
validateAgentSkill,
|
|
102
|
+
deriveHandoffs,
|
|
103
|
+
deriveStageAgent,
|
|
104
|
+
generateStageAgentProfile,
|
|
105
|
+
} from "./agent.js";
|
|
106
|
+
|
|
107
|
+
// Checklists
|
|
108
|
+
export { deriveChecklist, formatChecklistMarkdown } from "./checklist.js";
|
|
109
|
+
|
|
110
|
+
// Profile filtering (for agents)
|
|
111
|
+
export {
|
|
112
|
+
getPositiveTrackCapabilities,
|
|
113
|
+
filterHumanOnlySkills,
|
|
114
|
+
filterByHighestLevel,
|
|
115
|
+
filterSkillsForAgent,
|
|
116
|
+
sortByLevelDescending,
|
|
117
|
+
sortByMaturityDescending,
|
|
118
|
+
prepareBaseProfile,
|
|
119
|
+
AGENT_PROFILE_OPTIONS,
|
|
120
|
+
prepareAgentProfile,
|
|
121
|
+
} from "./profile.js";
|
package/lib/interview.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Default question time estimate if not specified
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_QUESTION_MINUTES = 5;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get questions from the question bank for a specific skill and level
|
|
22
|
+
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
23
|
+
* @param {string} skillId - The skill ID
|
|
24
|
+
* @param {string} level - The skill level
|
|
25
|
+
* @returns {import('./levels.js').Question[]} Array of questions
|
|
26
|
+
*/
|
|
27
|
+
function getSkillQuestions(questionBank, skillId, level) {
|
|
28
|
+
return questionBank.skillLevels?.[skillId]?.[level] || [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get questions from the question bank for a specific behaviour and maturity
|
|
33
|
+
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
34
|
+
* @param {string} behaviourId - The behaviour ID
|
|
35
|
+
* @param {string} maturity - The maturity level
|
|
36
|
+
* @returns {import('./levels.js').Question[]} Array of questions
|
|
37
|
+
*/
|
|
38
|
+
function getBehaviourQuestions(questionBank, behaviourId, maturity) {
|
|
39
|
+
return questionBank.behaviourMaturities?.[behaviourId]?.[maturity] || [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Calculate priority for a skill question
|
|
44
|
+
* @param {import('./levels.js').SkillMatrixEntry} skill - The skill entry
|
|
45
|
+
* @param {boolean} includeBelowLevel - Whether this is a below-level question
|
|
46
|
+
* @returns {number} Priority score (higher = more important)
|
|
47
|
+
*/
|
|
48
|
+
function calculateSkillPriority(skill, includeBelowLevel = false) {
|
|
49
|
+
let priority = 0;
|
|
50
|
+
|
|
51
|
+
// Primary skills are highest priority
|
|
52
|
+
if (skill.type === "primary") {
|
|
53
|
+
priority += 30;
|
|
54
|
+
} else if (skill.type === "secondary") {
|
|
55
|
+
priority += 20;
|
|
56
|
+
} else {
|
|
57
|
+
priority += 10;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// AI skills get a boost for "AI-era focus"
|
|
61
|
+
if (skill.capability === Capability.AI) {
|
|
62
|
+
priority += 15;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Delivery skills are core technical skills
|
|
66
|
+
if (skill.capability === Capability.DELIVERY) {
|
|
67
|
+
priority += 5;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Higher skill level = higher priority
|
|
71
|
+
priority += getSkillLevelIndex(skill.level) * 2;
|
|
72
|
+
|
|
73
|
+
// Below-level questions have lower priority
|
|
74
|
+
if (includeBelowLevel) {
|
|
75
|
+
priority -= 5;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return priority;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Calculate priority for a behaviour question
|
|
83
|
+
* @param {import('./levels.js').BehaviourProfileEntry} behaviour - The behaviour entry
|
|
84
|
+
* @returns {number} Priority score (higher = more important)
|
|
85
|
+
*/
|
|
86
|
+
function calculateBehaviourPriority(behaviour) {
|
|
87
|
+
let priority = 15;
|
|
88
|
+
|
|
89
|
+
// Higher maturity level = higher priority
|
|
90
|
+
priority += getBehaviourMaturityIndex(behaviour.maturity) * 3;
|
|
91
|
+
|
|
92
|
+
return priority;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Select a random question from an array (or first if deterministic)
|
|
97
|
+
* @param {import('./levels.js').Question[]} questions - Array of questions
|
|
98
|
+
* @param {boolean} deterministic - If true, always select first question
|
|
99
|
+
* @returns {import('./levels.js').Question|null} Selected question or null
|
|
100
|
+
*/
|
|
101
|
+
function selectQuestion(questions, deterministic = false) {
|
|
102
|
+
if (!questions || questions.length === 0) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (deterministic) {
|
|
106
|
+
return questions[0];
|
|
107
|
+
}
|
|
108
|
+
return questions[Math.floor(Math.random() * questions.length)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Derive interview questions for a job
|
|
113
|
+
* @param {Object} params
|
|
114
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
115
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
116
|
+
* @param {Object} [params.options] - Generation options
|
|
117
|
+
* @param {boolean} [params.options.includeBelowLevel=true] - Include one question from level below
|
|
118
|
+
* @param {boolean} [params.options.deterministic=false] - Use deterministic selection
|
|
119
|
+
* @param {number} [params.options.maxQuestionsPerSkill=2] - Max questions per skill
|
|
120
|
+
* @param {number} [params.options.maxQuestionsPerBehaviour=2] - Max questions per behaviour
|
|
121
|
+
* @param {number} [params.options.targetMinutes=60] - Target interview length in minutes
|
|
122
|
+
* @param {number} [params.options.skillBehaviourRatio=0.6] - Ratio of time for skills vs behaviours (0.6 = 60% skills, 40% behaviours)
|
|
123
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
124
|
+
*/
|
|
125
|
+
export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
126
|
+
const {
|
|
127
|
+
includeBelowLevel = true,
|
|
128
|
+
deterministic = false,
|
|
129
|
+
maxQuestionsPerSkill = 2,
|
|
130
|
+
maxQuestionsPerBehaviour = 2,
|
|
131
|
+
targetMinutes = 60,
|
|
132
|
+
skillBehaviourRatio = 0.6,
|
|
133
|
+
} = options;
|
|
134
|
+
|
|
135
|
+
const allSkillQuestions = [];
|
|
136
|
+
const allBehaviourQuestions = [];
|
|
137
|
+
const coveredSkills = new Set();
|
|
138
|
+
const coveredBehaviours = new Set();
|
|
139
|
+
|
|
140
|
+
// Generate all potential skill questions with priority
|
|
141
|
+
for (const skill of job.skillMatrix) {
|
|
142
|
+
const targetLevel = skill.level;
|
|
143
|
+
const targetLevelIndex = getSkillLevelIndex(targetLevel);
|
|
144
|
+
|
|
145
|
+
// Get questions at target level
|
|
146
|
+
const targetQuestions = getSkillQuestions(
|
|
147
|
+
questionBank,
|
|
148
|
+
skill.skillId,
|
|
149
|
+
targetLevel,
|
|
150
|
+
);
|
|
151
|
+
let questionsAdded = 0;
|
|
152
|
+
|
|
153
|
+
// Add question(s) at target level
|
|
154
|
+
for (const question of targetQuestions) {
|
|
155
|
+
if (questionsAdded >= maxQuestionsPerSkill) break;
|
|
156
|
+
|
|
157
|
+
allSkillQuestions.push({
|
|
158
|
+
question,
|
|
159
|
+
targetId: skill.skillId,
|
|
160
|
+
targetName: skill.skillName,
|
|
161
|
+
targetType: "skill",
|
|
162
|
+
targetLevel,
|
|
163
|
+
priority: calculateSkillPriority(skill, false),
|
|
164
|
+
});
|
|
165
|
+
questionsAdded++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Optionally add question from level below
|
|
169
|
+
if (
|
|
170
|
+
includeBelowLevel &&
|
|
171
|
+
targetLevelIndex > 0 &&
|
|
172
|
+
questionsAdded < maxQuestionsPerSkill
|
|
173
|
+
) {
|
|
174
|
+
const belowLevel = SKILL_LEVEL_ORDER[targetLevelIndex - 1];
|
|
175
|
+
const belowQuestions = getSkillQuestions(
|
|
176
|
+
questionBank,
|
|
177
|
+
skill.skillId,
|
|
178
|
+
belowLevel,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const belowQuestion = selectQuestion(belowQuestions, deterministic);
|
|
182
|
+
if (belowQuestion) {
|
|
183
|
+
allSkillQuestions.push({
|
|
184
|
+
question: belowQuestion,
|
|
185
|
+
targetId: skill.skillId,
|
|
186
|
+
targetName: skill.skillName,
|
|
187
|
+
targetType: "skill",
|
|
188
|
+
targetLevel: belowLevel,
|
|
189
|
+
priority: calculateSkillPriority(skill, true),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Generate all potential behaviour questions with priority
|
|
196
|
+
for (const behaviour of job.behaviourProfile) {
|
|
197
|
+
const targetMaturity = behaviour.maturity;
|
|
198
|
+
const questions = getBehaviourQuestions(
|
|
199
|
+
questionBank,
|
|
200
|
+
behaviour.behaviourId,
|
|
201
|
+
targetMaturity,
|
|
202
|
+
);
|
|
203
|
+
let questionsAdded = 0;
|
|
204
|
+
|
|
205
|
+
for (const question of questions) {
|
|
206
|
+
if (questionsAdded >= maxQuestionsPerBehaviour) break;
|
|
207
|
+
|
|
208
|
+
allBehaviourQuestions.push({
|
|
209
|
+
question,
|
|
210
|
+
targetId: behaviour.behaviourId,
|
|
211
|
+
targetName: behaviour.behaviourName,
|
|
212
|
+
targetType: "behaviour",
|
|
213
|
+
targetLevel: targetMaturity,
|
|
214
|
+
priority: calculateBehaviourPriority(behaviour),
|
|
215
|
+
});
|
|
216
|
+
questionsAdded++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Sort both lists by priority (highest first)
|
|
221
|
+
allSkillQuestions.sort((a, b) => b.priority - a.priority);
|
|
222
|
+
allBehaviourQuestions.sort((a, b) => b.priority - a.priority);
|
|
223
|
+
|
|
224
|
+
// Calculate time budgets
|
|
225
|
+
const skillTimeBudget = targetMinutes * skillBehaviourRatio;
|
|
226
|
+
const behaviourTimeBudget = targetMinutes * (1 - skillBehaviourRatio);
|
|
227
|
+
|
|
228
|
+
// Select skill questions within budget, prioritizing coverage diversity
|
|
229
|
+
// First pass: one question per skill (highest priority first)
|
|
230
|
+
const selectedQuestions = [];
|
|
231
|
+
const selectedSkillIds = new Set();
|
|
232
|
+
let skillMinutes = 0;
|
|
233
|
+
|
|
234
|
+
for (const q of allSkillQuestions) {
|
|
235
|
+
if (selectedSkillIds.has(q.targetId)) continue; // Skip if we already have this skill
|
|
236
|
+
const questionTime =
|
|
237
|
+
q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
238
|
+
if (skillMinutes + questionTime <= skillTimeBudget + 5) {
|
|
239
|
+
selectedQuestions.push(q);
|
|
240
|
+
selectedSkillIds.add(q.targetId);
|
|
241
|
+
coveredSkills.add(q.targetId);
|
|
242
|
+
skillMinutes += questionTime;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Second pass: add more questions if time allows
|
|
247
|
+
for (const q of allSkillQuestions) {
|
|
248
|
+
if (selectedQuestions.includes(q)) continue; // Skip already selected
|
|
249
|
+
const questionTime =
|
|
250
|
+
q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
251
|
+
if (skillMinutes + questionTime <= skillTimeBudget + 5) {
|
|
252
|
+
selectedQuestions.push(q);
|
|
253
|
+
coveredSkills.add(q.targetId);
|
|
254
|
+
skillMinutes += questionTime;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Select behaviour questions within budget, prioritizing coverage diversity
|
|
259
|
+
// First pass: one question per behaviour (highest priority first)
|
|
260
|
+
const selectedBehaviourIds = new Set();
|
|
261
|
+
let behaviourMinutes = 0;
|
|
262
|
+
|
|
263
|
+
for (const q of allBehaviourQuestions) {
|
|
264
|
+
if (selectedBehaviourIds.has(q.targetId)) continue; // Skip if we already have this behaviour
|
|
265
|
+
const questionTime =
|
|
266
|
+
q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
267
|
+
if (behaviourMinutes + questionTime <= behaviourTimeBudget + 5) {
|
|
268
|
+
selectedQuestions.push(q);
|
|
269
|
+
selectedBehaviourIds.add(q.targetId);
|
|
270
|
+
coveredBehaviours.add(q.targetId);
|
|
271
|
+
behaviourMinutes += questionTime;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Second pass: add more behaviour questions if time allows
|
|
276
|
+
for (const q of allBehaviourQuestions) {
|
|
277
|
+
if (selectedQuestions.includes(q)) continue; // Skip already selected
|
|
278
|
+
const questionTime =
|
|
279
|
+
q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
280
|
+
if (behaviourMinutes + questionTime <= behaviourTimeBudget + 5) {
|
|
281
|
+
selectedQuestions.push(q);
|
|
282
|
+
coveredBehaviours.add(q.targetId);
|
|
283
|
+
behaviourMinutes += questionTime;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Re-sort selected questions by priority
|
|
288
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
289
|
+
|
|
290
|
+
// Calculate total time
|
|
291
|
+
const expectedDurationMinutes = selectedQuestions.reduce(
|
|
292
|
+
(sum, q) =>
|
|
293
|
+
sum + (q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES),
|
|
294
|
+
0,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
job,
|
|
299
|
+
questions: selectedQuestions,
|
|
300
|
+
expectedDurationMinutes,
|
|
301
|
+
coverage: {
|
|
302
|
+
skills: Array.from(coveredSkills),
|
|
303
|
+
behaviours: Array.from(coveredBehaviours),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Derive a short/screening interview within a time budget
|
|
310
|
+
* @param {Object} params
|
|
311
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
312
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
313
|
+
* @param {number} [params.targetMinutes=20] - Target interview length in minutes
|
|
314
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
315
|
+
*/
|
|
316
|
+
export function deriveShortInterview({
|
|
317
|
+
job,
|
|
318
|
+
questionBank,
|
|
319
|
+
targetMinutes = 20,
|
|
320
|
+
}) {
|
|
321
|
+
// First get all potential questions with priority
|
|
322
|
+
const fullInterview = deriveInterviewQuestions({
|
|
323
|
+
job,
|
|
324
|
+
questionBank,
|
|
325
|
+
options: {
|
|
326
|
+
includeBelowLevel: false, // Skip below-level for short interviews
|
|
327
|
+
maxQuestionsPerSkill: 1,
|
|
328
|
+
maxQuestionsPerBehaviour: 1,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Select questions until we hit the time budget
|
|
333
|
+
const selectedQuestions = [];
|
|
334
|
+
let totalMinutes = 0;
|
|
335
|
+
const coveredSkills = new Set();
|
|
336
|
+
const coveredBehaviours = new Set();
|
|
337
|
+
|
|
338
|
+
// Ensure we have at least some skill and behaviour coverage
|
|
339
|
+
// by alternating between skill and behaviour questions
|
|
340
|
+
const skillQuestions = fullInterview.questions.filter(
|
|
341
|
+
(q) => q.targetType === "skill",
|
|
342
|
+
);
|
|
343
|
+
const behaviourQuestions = fullInterview.questions.filter(
|
|
344
|
+
(q) => q.targetType === "behaviour",
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
let skillIndex = 0;
|
|
348
|
+
let behaviourIndex = 0;
|
|
349
|
+
let preferSkill = true;
|
|
350
|
+
|
|
351
|
+
while (totalMinutes < targetMinutes) {
|
|
352
|
+
let nextQuestion = null;
|
|
353
|
+
|
|
354
|
+
if (preferSkill && skillIndex < skillQuestions.length) {
|
|
355
|
+
nextQuestion = skillQuestions[skillIndex++];
|
|
356
|
+
} else if (!preferSkill && behaviourIndex < behaviourQuestions.length) {
|
|
357
|
+
nextQuestion = behaviourQuestions[behaviourIndex++];
|
|
358
|
+
} else if (skillIndex < skillQuestions.length) {
|
|
359
|
+
nextQuestion = skillQuestions[skillIndex++];
|
|
360
|
+
} else if (behaviourIndex < behaviourQuestions.length) {
|
|
361
|
+
nextQuestion = behaviourQuestions[behaviourIndex++];
|
|
362
|
+
} else {
|
|
363
|
+
break; // No more questions
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const questionTime =
|
|
367
|
+
nextQuestion.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
368
|
+
|
|
369
|
+
// Don't exceed budget by too much
|
|
370
|
+
if (totalMinutes + questionTime > targetMinutes + 5) {
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
selectedQuestions.push(nextQuestion);
|
|
375
|
+
totalMinutes += questionTime;
|
|
376
|
+
|
|
377
|
+
if (nextQuestion.targetType === "skill") {
|
|
378
|
+
coveredSkills.add(nextQuestion.targetId);
|
|
379
|
+
} else {
|
|
380
|
+
coveredBehaviours.add(nextQuestion.targetId);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
preferSkill = !preferSkill;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Re-sort selected questions by priority
|
|
387
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
job,
|
|
391
|
+
questions: selectedQuestions,
|
|
392
|
+
expectedDurationMinutes: totalMinutes,
|
|
393
|
+
coverage: {
|
|
394
|
+
skills: Array.from(coveredSkills),
|
|
395
|
+
behaviours: Array.from(coveredBehaviours),
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Derive behaviour-focused interview questions
|
|
402
|
+
* @param {Object} params
|
|
403
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
404
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
405
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
406
|
+
*/
|
|
407
|
+
export function deriveBehaviourQuestions({ job, questionBank }) {
|
|
408
|
+
const interviewQuestions = [];
|
|
409
|
+
const coveredBehaviours = new Set();
|
|
410
|
+
|
|
411
|
+
// Focus only on behaviours, with more depth
|
|
412
|
+
for (const behaviour of job.behaviourProfile) {
|
|
413
|
+
const targetMaturity = behaviour.maturity;
|
|
414
|
+
|
|
415
|
+
// Get questions at target maturity
|
|
416
|
+
const targetQuestions = getBehaviourQuestions(
|
|
417
|
+
questionBank,
|
|
418
|
+
behaviour.behaviourId,
|
|
419
|
+
targetMaturity,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
for (const question of targetQuestions) {
|
|
423
|
+
interviewQuestions.push({
|
|
424
|
+
question,
|
|
425
|
+
targetId: behaviour.behaviourId,
|
|
426
|
+
targetName: behaviour.behaviourName,
|
|
427
|
+
targetType: "behaviour",
|
|
428
|
+
targetLevel: targetMaturity,
|
|
429
|
+
priority: calculateBehaviourPriority(behaviour),
|
|
430
|
+
});
|
|
431
|
+
coveredBehaviours.add(behaviour.behaviourId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Sort by priority
|
|
436
|
+
interviewQuestions.sort((a, b) => b.priority - a.priority);
|
|
437
|
+
|
|
438
|
+
// Calculate total time
|
|
439
|
+
const expectedDurationMinutes = interviewQuestions.reduce(
|
|
440
|
+
(sum, q) =>
|
|
441
|
+
sum + (q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES),
|
|
442
|
+
0,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
job,
|
|
447
|
+
questions: interviewQuestions,
|
|
448
|
+
expectedDurationMinutes,
|
|
449
|
+
coverage: {
|
|
450
|
+
skills: [],
|
|
451
|
+
behaviours: Array.from(coveredBehaviours),
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Generate a focused interview for specific skills/behaviours
|
|
458
|
+
* @param {Object} params
|
|
459
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
460
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
461
|
+
* @param {string[]} [params.focusSkills] - Skill IDs to focus on
|
|
462
|
+
* @param {string[]} [params.focusBehaviours] - Behaviour IDs to focus on
|
|
463
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
464
|
+
*/
|
|
465
|
+
export function deriveFocusedInterview({
|
|
466
|
+
job,
|
|
467
|
+
questionBank,
|
|
468
|
+
focusSkills = [],
|
|
469
|
+
focusBehaviours = [],
|
|
470
|
+
}) {
|
|
471
|
+
const interviewQuestions = [];
|
|
472
|
+
const coveredSkills = new Set();
|
|
473
|
+
const coveredBehaviours = new Set();
|
|
474
|
+
|
|
475
|
+
// Focus skills
|
|
476
|
+
const focusSkillSet = new Set(focusSkills);
|
|
477
|
+
for (const skill of job.skillMatrix) {
|
|
478
|
+
if (!focusSkillSet.has(skill.skillId)) continue;
|
|
479
|
+
|
|
480
|
+
const questions = getSkillQuestions(
|
|
481
|
+
questionBank,
|
|
482
|
+
skill.skillId,
|
|
483
|
+
skill.level,
|
|
484
|
+
);
|
|
485
|
+
for (const question of questions) {
|
|
486
|
+
interviewQuestions.push({
|
|
487
|
+
question,
|
|
488
|
+
targetId: skill.skillId,
|
|
489
|
+
targetName: skill.skillName,
|
|
490
|
+
targetType: "skill",
|
|
491
|
+
targetLevel: skill.level,
|
|
492
|
+
priority: calculateSkillPriority(skill) + 10, // Boost for focus
|
|
493
|
+
});
|
|
494
|
+
coveredSkills.add(skill.skillId);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Focus behaviours
|
|
499
|
+
const focusBehaviourSet = new Set(focusBehaviours);
|
|
500
|
+
for (const behaviour of job.behaviourProfile) {
|
|
501
|
+
if (!focusBehaviourSet.has(behaviour.behaviourId)) continue;
|
|
502
|
+
|
|
503
|
+
const questions = getBehaviourQuestions(
|
|
504
|
+
questionBank,
|
|
505
|
+
behaviour.behaviourId,
|
|
506
|
+
behaviour.maturity,
|
|
507
|
+
);
|
|
508
|
+
for (const question of questions) {
|
|
509
|
+
interviewQuestions.push({
|
|
510
|
+
question,
|
|
511
|
+
targetId: behaviour.behaviourId,
|
|
512
|
+
targetName: behaviour.behaviourName,
|
|
513
|
+
targetType: "behaviour",
|
|
514
|
+
targetLevel: behaviour.maturity,
|
|
515
|
+
priority: calculateBehaviourPriority(behaviour) + 10, // Boost for focus
|
|
516
|
+
});
|
|
517
|
+
coveredBehaviours.add(behaviour.behaviourId);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Sort by priority
|
|
522
|
+
interviewQuestions.sort((a, b) => b.priority - a.priority);
|
|
523
|
+
|
|
524
|
+
const expectedDurationMinutes = interviewQuestions.reduce(
|
|
525
|
+
(sum, q) =>
|
|
526
|
+
sum + (q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES),
|
|
527
|
+
0,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
job,
|
|
532
|
+
questions: interviewQuestions,
|
|
533
|
+
expectedDurationMinutes,
|
|
534
|
+
coverage: {
|
|
535
|
+
skills: Array.from(coveredSkills),
|
|
536
|
+
behaviours: Array.from(coveredBehaviours),
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|