@forwardimpact/model 0.4.0 → 0.7.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/README.md +2 -2
- package/package.json +17 -15
- package/{lib → src}/agent.js +20 -22
- package/{lib → src}/derivation.js +6 -9
- package/{lib → src}/index.js +59 -17
- package/{lib → src}/interview.js +450 -21
- package/{lib → src}/job-cache.js +7 -7
- package/{lib → src}/matching.js +38 -28
- package/{lib → src}/modifiers.js +4 -4
- package/src/policies/composed.js +111 -0
- package/src/policies/filters.js +104 -0
- package/src/policies/index.js +128 -0
- package/src/policies/orderings.js +300 -0
- package/src/policies/predicates.js +177 -0
- package/src/policies/thresholds.js +160 -0
- package/src/profile.js +182 -0
- package/{lib → src}/progression.js +8 -13
- package/{lib → src}/toolkit.js +3 -3
- package/lib/profile.js +0 -262
- /package/{lib → src}/checklist.js +0 -0
- /package/{lib → src}/job.js +0 -0
package/{lib → src}/interview.js
RENAMED
|
@@ -12,20 +12,46 @@ import {
|
|
|
12
12
|
Capability,
|
|
13
13
|
} from "@forwardimpact/schema/levels";
|
|
14
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
|
+
} from "./policies/thresholds.js";
|
|
24
|
+
|
|
15
25
|
/**
|
|
16
26
|
* Default question time estimate if not specified
|
|
17
27
|
*/
|
|
18
28
|
const DEFAULT_QUESTION_MINUTES = 5;
|
|
19
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Default decomposition question time estimate
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_DECOMPOSITION_MINUTES = 15;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default stakeholder simulation question time estimate
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_SIMULATION_MINUTES = 20;
|
|
39
|
+
|
|
20
40
|
/**
|
|
21
41
|
* Get questions from the question bank for a specific skill and level
|
|
22
42
|
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
23
43
|
* @param {string} skillId - The skill ID
|
|
24
44
|
* @param {string} level - The skill level
|
|
45
|
+
* @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
25
46
|
* @returns {import('./levels.js').Question[]} Array of questions
|
|
26
47
|
*/
|
|
27
|
-
function getSkillQuestions(
|
|
28
|
-
|
|
48
|
+
function getSkillQuestions(
|
|
49
|
+
questionBank,
|
|
50
|
+
skillId,
|
|
51
|
+
level,
|
|
52
|
+
roleType = "professionalQuestions",
|
|
53
|
+
) {
|
|
54
|
+
return questionBank.skillLevels?.[skillId]?.[roleType]?.[level] || [];
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
/**
|
|
@@ -33,10 +59,64 @@ function getSkillQuestions(questionBank, skillId, level) {
|
|
|
33
59
|
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
34
60
|
* @param {string} behaviourId - The behaviour ID
|
|
35
61
|
* @param {string} maturity - The maturity level
|
|
62
|
+
* @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
36
63
|
* @returns {import('./levels.js').Question[]} Array of questions
|
|
37
64
|
*/
|
|
38
|
-
function getBehaviourQuestions(
|
|
39
|
-
|
|
65
|
+
function getBehaviourQuestions(
|
|
66
|
+
questionBank,
|
|
67
|
+
behaviourId,
|
|
68
|
+
maturity,
|
|
69
|
+
roleType = "professionalQuestions",
|
|
70
|
+
) {
|
|
71
|
+
return (
|
|
72
|
+
questionBank.behaviourMaturities?.[behaviourId]?.[roleType]?.[maturity] ||
|
|
73
|
+
[]
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get decomposition questions from the question bank for a specific capability and level
|
|
79
|
+
* @param {import('./levels.js').QuestionBank} questionBank - The question bank
|
|
80
|
+
* @param {string} capabilityId - The capability ID
|
|
81
|
+
* @param {string} level - The skill level (capabilities use same levels as skills)
|
|
82
|
+
* @param {string} [roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
83
|
+
* @returns {import('./levels.js').Question[]} Array of questions
|
|
84
|
+
*/
|
|
85
|
+
function getCapabilityQuestions(
|
|
86
|
+
questionBank,
|
|
87
|
+
capabilityId,
|
|
88
|
+
level,
|
|
89
|
+
roleType = "professionalQuestions",
|
|
90
|
+
) {
|
|
91
|
+
return (
|
|
92
|
+
questionBank.capabilityLevels?.[capabilityId]?.[roleType]?.[level] || []
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Derive capability levels from a job's skill matrix
|
|
98
|
+
* Uses the maximum skill level in each capability.
|
|
99
|
+
* @param {import('./levels.js').JobDefinition} job - The job definition
|
|
100
|
+
* @returns {Map<string, {capabilityId: string, level: string, levelIndex: number}>} Map of capability to level info
|
|
101
|
+
*/
|
|
102
|
+
function deriveCapabilityLevels(job) {
|
|
103
|
+
const capabilityLevels = new Map();
|
|
104
|
+
|
|
105
|
+
for (const skill of job.skillMatrix) {
|
|
106
|
+
const capabilityId = skill.capability;
|
|
107
|
+
const levelIndex = getSkillLevelIndex(skill.level);
|
|
108
|
+
|
|
109
|
+
const existing = capabilityLevels.get(capabilityId);
|
|
110
|
+
if (!existing || levelIndex > existing.levelIndex) {
|
|
111
|
+
capabilityLevels.set(capabilityId, {
|
|
112
|
+
capabilityId,
|
|
113
|
+
level: skill.level,
|
|
114
|
+
levelIndex,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return capabilityLevels;
|
|
40
120
|
}
|
|
41
121
|
|
|
42
122
|
/**
|
|
@@ -48,31 +128,25 @@ function getBehaviourQuestions(questionBank, behaviourId, maturity) {
|
|
|
48
128
|
function calculateSkillPriority(skill, includeBelowLevel = false) {
|
|
49
129
|
let priority = 0;
|
|
50
130
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
priority += 30;
|
|
54
|
-
} else if (skill.type === "secondary") {
|
|
55
|
-
priority += 20;
|
|
56
|
-
} else {
|
|
57
|
-
priority += 10;
|
|
58
|
-
}
|
|
131
|
+
// Skill type priority from policy weights
|
|
132
|
+
priority += WEIGHT_SKILL_TYPE[skill.type] || WEIGHT_SKILL_TYPE.broad;
|
|
59
133
|
|
|
60
134
|
// AI skills get a boost for "AI-era focus"
|
|
61
135
|
if (skill.capability === Capability.AI) {
|
|
62
|
-
priority +=
|
|
136
|
+
priority += WEIGHT_CAPABILITY_BOOST.ai;
|
|
63
137
|
}
|
|
64
138
|
|
|
65
139
|
// Delivery skills are core technical skills
|
|
66
140
|
if (skill.capability === Capability.DELIVERY) {
|
|
67
|
-
priority +=
|
|
141
|
+
priority += WEIGHT_CAPABILITY_BOOST.delivery;
|
|
68
142
|
}
|
|
69
143
|
|
|
70
144
|
// Higher skill level = higher priority
|
|
71
|
-
priority += getSkillLevelIndex(skill.level) *
|
|
145
|
+
priority += getSkillLevelIndex(skill.level) * WEIGHT_SKILL_LEVEL;
|
|
72
146
|
|
|
73
147
|
// Below-level questions have lower priority
|
|
74
148
|
if (includeBelowLevel) {
|
|
75
|
-
priority
|
|
149
|
+
priority += WEIGHT_BELOW_LEVEL_PENALTY;
|
|
76
150
|
}
|
|
77
151
|
|
|
78
152
|
return priority;
|
|
@@ -84,10 +158,35 @@ function calculateSkillPriority(skill, includeBelowLevel = false) {
|
|
|
84
158
|
* @returns {number} Priority score (higher = more important)
|
|
85
159
|
*/
|
|
86
160
|
function calculateBehaviourPriority(behaviour) {
|
|
87
|
-
let priority =
|
|
161
|
+
let priority = WEIGHT_BEHAVIOUR_BASE;
|
|
88
162
|
|
|
89
163
|
// Higher maturity level = higher priority
|
|
90
|
-
priority +=
|
|
164
|
+
priority +=
|
|
165
|
+
getBehaviourMaturityIndex(behaviour.maturity) * WEIGHT_BEHAVIOUR_MATURITY;
|
|
166
|
+
|
|
167
|
+
return priority;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Calculate priority for a capability decomposition question
|
|
172
|
+
* @param {string} capabilityId - The capability ID
|
|
173
|
+
* @param {number} levelIndex - The skill level index
|
|
174
|
+
* @returns {number} Priority score (higher = more important)
|
|
175
|
+
*/
|
|
176
|
+
function calculateCapabilityPriority(capabilityId, levelIndex) {
|
|
177
|
+
let priority = 0;
|
|
178
|
+
|
|
179
|
+
// Delivery and scale capabilities are typically more important for decomposition
|
|
180
|
+
if (capabilityId === Capability.DELIVERY) {
|
|
181
|
+
priority += 10;
|
|
182
|
+
} else if (capabilityId === Capability.SCALE) {
|
|
183
|
+
priority += 8;
|
|
184
|
+
} else if (capabilityId === Capability.RELIABILITY) {
|
|
185
|
+
priority += 6;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Higher level = higher priority
|
|
189
|
+
priority += levelIndex * WEIGHT_SKILL_LEVEL;
|
|
91
190
|
|
|
92
191
|
return priority;
|
|
93
192
|
}
|
|
@@ -119,7 +218,8 @@ function selectQuestion(questions, deterministic = false) {
|
|
|
119
218
|
* @param {number} [params.options.maxQuestionsPerSkill=2] - Max questions per skill
|
|
120
219
|
* @param {number} [params.options.maxQuestionsPerBehaviour=2] - Max questions per behaviour
|
|
121
220
|
* @param {number} [params.options.targetMinutes=60] - Target interview length in minutes
|
|
122
|
-
* @param {number} [params.options.skillBehaviourRatio=
|
|
221
|
+
* @param {number} [params.options.skillBehaviourRatio=RATIO_SKILL_BEHAVIOUR] - Ratio of time for skills vs behaviours
|
|
222
|
+
* @param {string} [params.options.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
123
223
|
* @returns {import('./levels.js').InterviewGuide}
|
|
124
224
|
*/
|
|
125
225
|
export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
@@ -129,7 +229,8 @@ export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
|
129
229
|
maxQuestionsPerSkill = 2,
|
|
130
230
|
maxQuestionsPerBehaviour = 2,
|
|
131
231
|
targetMinutes = 60,
|
|
132
|
-
skillBehaviourRatio =
|
|
232
|
+
skillBehaviourRatio = RATIO_SKILL_BEHAVIOUR,
|
|
233
|
+
roleType = "professionalQuestions",
|
|
133
234
|
} = options;
|
|
134
235
|
|
|
135
236
|
const allSkillQuestions = [];
|
|
@@ -147,6 +248,7 @@ export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
|
147
248
|
questionBank,
|
|
148
249
|
skill.skillId,
|
|
149
250
|
targetLevel,
|
|
251
|
+
roleType,
|
|
150
252
|
);
|
|
151
253
|
let questionsAdded = 0;
|
|
152
254
|
|
|
@@ -176,6 +278,7 @@ export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
|
176
278
|
questionBank,
|
|
177
279
|
skill.skillId,
|
|
178
280
|
belowLevel,
|
|
281
|
+
roleType,
|
|
179
282
|
);
|
|
180
283
|
|
|
181
284
|
const belowQuestion = selectQuestion(belowQuestions, deterministic);
|
|
@@ -199,6 +302,8 @@ export function deriveInterviewQuestions({ job, questionBank, options = {} }) {
|
|
|
199
302
|
questionBank,
|
|
200
303
|
behaviour.behaviourId,
|
|
201
304
|
targetMaturity,
|
|
305
|
+
roleType,
|
|
306
|
+
targetMaturity,
|
|
202
307
|
);
|
|
203
308
|
let questionsAdded = 0;
|
|
204
309
|
|
|
@@ -402,9 +507,14 @@ export function deriveShortInterview({
|
|
|
402
507
|
* @param {Object} params
|
|
403
508
|
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
404
509
|
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
510
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
405
511
|
* @returns {import('./levels.js').InterviewGuide}
|
|
406
512
|
*/
|
|
407
|
-
export function deriveBehaviourQuestions({
|
|
513
|
+
export function deriveBehaviourQuestions({
|
|
514
|
+
job,
|
|
515
|
+
questionBank,
|
|
516
|
+
roleType = "professionalQuestions",
|
|
517
|
+
}) {
|
|
408
518
|
const interviewQuestions = [];
|
|
409
519
|
const coveredBehaviours = new Set();
|
|
410
520
|
|
|
@@ -417,6 +527,7 @@ export function deriveBehaviourQuestions({ job, questionBank }) {
|
|
|
417
527
|
questionBank,
|
|
418
528
|
behaviour.behaviourId,
|
|
419
529
|
targetMaturity,
|
|
530
|
+
roleType,
|
|
420
531
|
);
|
|
421
532
|
|
|
422
533
|
for (const question of targetQuestions) {
|
|
@@ -460,6 +571,7 @@ export function deriveBehaviourQuestions({ job, questionBank }) {
|
|
|
460
571
|
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
461
572
|
* @param {string[]} [params.focusSkills] - Skill IDs to focus on
|
|
462
573
|
* @param {string[]} [params.focusBehaviours] - Behaviour IDs to focus on
|
|
574
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
463
575
|
* @returns {import('./levels.js').InterviewGuide}
|
|
464
576
|
*/
|
|
465
577
|
export function deriveFocusedInterview({
|
|
@@ -467,6 +579,7 @@ export function deriveFocusedInterview({
|
|
|
467
579
|
questionBank,
|
|
468
580
|
focusSkills = [],
|
|
469
581
|
focusBehaviours = [],
|
|
582
|
+
roleType = "professionalQuestions",
|
|
470
583
|
}) {
|
|
471
584
|
const interviewQuestions = [];
|
|
472
585
|
const coveredSkills = new Set();
|
|
@@ -481,6 +594,7 @@ export function deriveFocusedInterview({
|
|
|
481
594
|
questionBank,
|
|
482
595
|
skill.skillId,
|
|
483
596
|
skill.level,
|
|
597
|
+
roleType,
|
|
484
598
|
);
|
|
485
599
|
for (const question of questions) {
|
|
486
600
|
interviewQuestions.push({
|
|
@@ -504,6 +618,7 @@ export function deriveFocusedInterview({
|
|
|
504
618
|
questionBank,
|
|
505
619
|
behaviour.behaviourId,
|
|
506
620
|
behaviour.maturity,
|
|
621
|
+
roleType,
|
|
507
622
|
);
|
|
508
623
|
for (const question of questions) {
|
|
509
624
|
interviewQuestions.push({
|
|
@@ -537,3 +652,317 @@ export function deriveFocusedInterview({
|
|
|
537
652
|
},
|
|
538
653
|
};
|
|
539
654
|
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Derive Mission Fit interview questions (skill-focused)
|
|
658
|
+
*
|
|
659
|
+
* 45-minute interview with Recruiting Manager + 1 Senior Engineer
|
|
660
|
+
* Focuses on skill questions to assess technical capability and fit.
|
|
661
|
+
*
|
|
662
|
+
* @param {Object} params
|
|
663
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
664
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
665
|
+
* @param {number} [params.targetMinutes=45] - Target interview length in minutes
|
|
666
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
667
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
668
|
+
*/
|
|
669
|
+
export function deriveMissionFitInterview({
|
|
670
|
+
job,
|
|
671
|
+
questionBank,
|
|
672
|
+
targetMinutes = 45,
|
|
673
|
+
roleType = "professionalQuestions",
|
|
674
|
+
}) {
|
|
675
|
+
const allSkillQuestions = [];
|
|
676
|
+
const coveredSkills = new Set();
|
|
677
|
+
|
|
678
|
+
// Generate all potential skill questions with priority
|
|
679
|
+
for (const skill of job.skillMatrix) {
|
|
680
|
+
const targetLevel = skill.level;
|
|
681
|
+
const targetLevelIndex = getSkillLevelIndex(targetLevel);
|
|
682
|
+
|
|
683
|
+
// Get questions at target level
|
|
684
|
+
const targetQuestions = getSkillQuestions(
|
|
685
|
+
questionBank,
|
|
686
|
+
skill.skillId,
|
|
687
|
+
targetLevel,
|
|
688
|
+
roleType,
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
for (const question of targetQuestions) {
|
|
692
|
+
allSkillQuestions.push({
|
|
693
|
+
question,
|
|
694
|
+
targetId: skill.skillId,
|
|
695
|
+
targetName: skill.skillName,
|
|
696
|
+
targetType: "skill",
|
|
697
|
+
targetLevel,
|
|
698
|
+
priority: calculateSkillPriority(skill, false),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Also add question from level below for depth
|
|
703
|
+
if (targetLevelIndex > 0) {
|
|
704
|
+
const belowLevel = SKILL_LEVEL_ORDER[targetLevelIndex - 1];
|
|
705
|
+
const belowQuestions = getSkillQuestions(
|
|
706
|
+
questionBank,
|
|
707
|
+
skill.skillId,
|
|
708
|
+
belowLevel,
|
|
709
|
+
roleType,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
for (const question of belowQuestions) {
|
|
713
|
+
allSkillQuestions.push({
|
|
714
|
+
question,
|
|
715
|
+
targetId: skill.skillId,
|
|
716
|
+
targetName: skill.skillName,
|
|
717
|
+
targetType: "skill",
|
|
718
|
+
targetLevel: belowLevel,
|
|
719
|
+
priority: calculateSkillPriority(skill, true),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Sort by priority (highest first)
|
|
726
|
+
allSkillQuestions.sort((a, b) => b.priority - a.priority);
|
|
727
|
+
|
|
728
|
+
// Select questions within budget, prioritizing coverage diversity
|
|
729
|
+
const selectedQuestions = [];
|
|
730
|
+
const selectedSkillIds = new Set();
|
|
731
|
+
let totalMinutes = 0;
|
|
732
|
+
|
|
733
|
+
// First pass: one question per skill (highest priority first)
|
|
734
|
+
for (const q of allSkillQuestions) {
|
|
735
|
+
if (selectedSkillIds.has(q.targetId)) continue;
|
|
736
|
+
const questionTime =
|
|
737
|
+
q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
738
|
+
if (totalMinutes + questionTime <= targetMinutes + 5) {
|
|
739
|
+
selectedQuestions.push(q);
|
|
740
|
+
selectedSkillIds.add(q.targetId);
|
|
741
|
+
coveredSkills.add(q.targetId);
|
|
742
|
+
totalMinutes += questionTime;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Second pass: add more questions if time allows
|
|
747
|
+
for (const q of allSkillQuestions) {
|
|
748
|
+
if (selectedQuestions.includes(q)) continue;
|
|
749
|
+
const questionTime =
|
|
750
|
+
q.question.expectedDurationMinutes || DEFAULT_QUESTION_MINUTES;
|
|
751
|
+
if (totalMinutes + questionTime <= targetMinutes + 5) {
|
|
752
|
+
selectedQuestions.push(q);
|
|
753
|
+
coveredSkills.add(q.targetId);
|
|
754
|
+
totalMinutes += questionTime;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Re-sort by priority
|
|
759
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
job,
|
|
763
|
+
questions: selectedQuestions,
|
|
764
|
+
expectedDurationMinutes: totalMinutes,
|
|
765
|
+
coverage: {
|
|
766
|
+
skills: Array.from(coveredSkills),
|
|
767
|
+
behaviours: [],
|
|
768
|
+
capabilities: [],
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Derive Decomposition interview questions (capability-focused)
|
|
775
|
+
*
|
|
776
|
+
* 60-minute interview with 2 Senior Engineers
|
|
777
|
+
* Focuses on capability decomposition questions inspired by Palantir's technique.
|
|
778
|
+
* Capabilities are selected based on the job's skill matrix.
|
|
779
|
+
*
|
|
780
|
+
* @param {Object} params
|
|
781
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
782
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
783
|
+
* @param {number} [params.targetMinutes=60] - Target interview length in minutes
|
|
784
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
785
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
786
|
+
*/
|
|
787
|
+
export function deriveDecompositionInterview({
|
|
788
|
+
job,
|
|
789
|
+
questionBank,
|
|
790
|
+
targetMinutes = 60,
|
|
791
|
+
roleType = "professionalQuestions",
|
|
792
|
+
}) {
|
|
793
|
+
const allCapabilityQuestions = [];
|
|
794
|
+
const coveredCapabilities = new Set();
|
|
795
|
+
|
|
796
|
+
// Derive capability levels from the job's skill matrix
|
|
797
|
+
const capabilityLevels = deriveCapabilityLevels(job);
|
|
798
|
+
|
|
799
|
+
// Generate capability questions based on derived levels
|
|
800
|
+
for (const [capabilityId, levelInfo] of capabilityLevels) {
|
|
801
|
+
const { level, levelIndex } = levelInfo;
|
|
802
|
+
|
|
803
|
+
// Get questions at the derived level
|
|
804
|
+
const questions = getCapabilityQuestions(
|
|
805
|
+
questionBank,
|
|
806
|
+
capabilityId,
|
|
807
|
+
level,
|
|
808
|
+
roleType,
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
for (const question of questions) {
|
|
812
|
+
allCapabilityQuestions.push({
|
|
813
|
+
question,
|
|
814
|
+
targetId: capabilityId,
|
|
815
|
+
targetName: capabilityId, // Capability name can be enhanced if needed
|
|
816
|
+
targetType: "capability",
|
|
817
|
+
targetLevel: level,
|
|
818
|
+
priority: calculateCapabilityPriority(capabilityId, levelIndex),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Also try level below if available
|
|
823
|
+
if (levelIndex > 0) {
|
|
824
|
+
const belowLevel = SKILL_LEVEL_ORDER[levelIndex - 1];
|
|
825
|
+
const belowQuestions = getCapabilityQuestions(
|
|
826
|
+
questionBank,
|
|
827
|
+
capabilityId,
|
|
828
|
+
belowLevel,
|
|
829
|
+
roleType,
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
for (const question of belowQuestions) {
|
|
833
|
+
allCapabilityQuestions.push({
|
|
834
|
+
question,
|
|
835
|
+
targetId: capabilityId,
|
|
836
|
+
targetName: capabilityId,
|
|
837
|
+
targetType: "capability",
|
|
838
|
+
targetLevel: belowLevel,
|
|
839
|
+
priority: calculateCapabilityPriority(capabilityId, levelIndex - 1),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Sort by priority (highest first)
|
|
846
|
+
allCapabilityQuestions.sort((a, b) => b.priority - a.priority);
|
|
847
|
+
|
|
848
|
+
// Select questions within budget, prioritizing coverage diversity
|
|
849
|
+
const selectedQuestions = [];
|
|
850
|
+
const selectedCapabilityIds = new Set();
|
|
851
|
+
let totalMinutes = 0;
|
|
852
|
+
|
|
853
|
+
// First pass: one question per capability (highest priority first)
|
|
854
|
+
for (const q of allCapabilityQuestions) {
|
|
855
|
+
if (selectedCapabilityIds.has(q.targetId)) continue;
|
|
856
|
+
const questionTime =
|
|
857
|
+
q.question.expectedDurationMinutes || DEFAULT_DECOMPOSITION_MINUTES;
|
|
858
|
+
if (totalMinutes + questionTime <= targetMinutes + 5) {
|
|
859
|
+
selectedQuestions.push(q);
|
|
860
|
+
selectedCapabilityIds.add(q.targetId);
|
|
861
|
+
coveredCapabilities.add(q.targetId);
|
|
862
|
+
totalMinutes += questionTime;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Second pass: add more questions if time allows
|
|
867
|
+
for (const q of allCapabilityQuestions) {
|
|
868
|
+
if (selectedQuestions.includes(q)) continue;
|
|
869
|
+
const questionTime =
|
|
870
|
+
q.question.expectedDurationMinutes || DEFAULT_DECOMPOSITION_MINUTES;
|
|
871
|
+
if (totalMinutes + questionTime <= targetMinutes + 5) {
|
|
872
|
+
selectedQuestions.push(q);
|
|
873
|
+
coveredCapabilities.add(q.targetId);
|
|
874
|
+
totalMinutes += questionTime;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Re-sort by priority
|
|
879
|
+
selectedQuestions.sort((a, b) => b.priority - a.priority);
|
|
880
|
+
|
|
881
|
+
return {
|
|
882
|
+
job,
|
|
883
|
+
questions: selectedQuestions,
|
|
884
|
+
expectedDurationMinutes: totalMinutes,
|
|
885
|
+
coverage: {
|
|
886
|
+
skills: [],
|
|
887
|
+
behaviours: [],
|
|
888
|
+
capabilities: Array.from(coveredCapabilities),
|
|
889
|
+
},
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Derive Stakeholder Simulation interview questions (behaviour-focused)
|
|
895
|
+
*
|
|
896
|
+
* 60-minute interview with 3-4 stakeholders.
|
|
897
|
+
* Selects the highest-maturity behaviours for the role and picks one chunky
|
|
898
|
+
* simulation question per behaviour. For most jobs this means 2-3 behaviours
|
|
899
|
+
* with ~20-minute simulation scenarios each.
|
|
900
|
+
*
|
|
901
|
+
* @param {Object} params
|
|
902
|
+
* @param {import('./levels.js').JobDefinition} params.job - The job definition
|
|
903
|
+
* @param {import('./levels.js').QuestionBank} params.questionBank - The question bank
|
|
904
|
+
* @param {number} [params.targetMinutes=60] - Target interview length in minutes
|
|
905
|
+
* @param {string} [params.roleType='professionalQuestions'] - Role type ('professionalQuestions' or 'managementQuestions')
|
|
906
|
+
* @returns {import('./levels.js').InterviewGuide}
|
|
907
|
+
*/
|
|
908
|
+
export function deriveStakeholderInterview({
|
|
909
|
+
job,
|
|
910
|
+
questionBank,
|
|
911
|
+
targetMinutes = 60,
|
|
912
|
+
roleType = "professionalQuestions",
|
|
913
|
+
}) {
|
|
914
|
+
const coveredBehaviours = new Set();
|
|
915
|
+
|
|
916
|
+
// Sort behaviours by maturity (highest first) to prioritize the most emphasized
|
|
917
|
+
const sortedBehaviours = [...job.behaviourProfile].sort(
|
|
918
|
+
(a, b) =>
|
|
919
|
+
getBehaviourMaturityIndex(b.maturity) -
|
|
920
|
+
getBehaviourMaturityIndex(a.maturity),
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// Select one question per behaviour, highest maturity first, within budget
|
|
924
|
+
const selectedQuestions = [];
|
|
925
|
+
let totalMinutes = 0;
|
|
926
|
+
|
|
927
|
+
for (const behaviour of sortedBehaviours) {
|
|
928
|
+
const targetMaturity = behaviour.maturity;
|
|
929
|
+
|
|
930
|
+
// Get questions at target maturity
|
|
931
|
+
const questions = getBehaviourQuestions(
|
|
932
|
+
questionBank,
|
|
933
|
+
behaviour.behaviourId,
|
|
934
|
+
targetMaturity,
|
|
935
|
+
roleType,
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
// Pick the first available question (1 per behaviour)
|
|
939
|
+
const question = questions[0];
|
|
940
|
+
if (!question) continue;
|
|
941
|
+
|
|
942
|
+
const questionTime =
|
|
943
|
+
question.expectedDurationMinutes || DEFAULT_SIMULATION_MINUTES;
|
|
944
|
+
if (totalMinutes + questionTime > targetMinutes + 5) break;
|
|
945
|
+
|
|
946
|
+
selectedQuestions.push({
|
|
947
|
+
question,
|
|
948
|
+
targetId: behaviour.behaviourId,
|
|
949
|
+
targetName: behaviour.behaviourName,
|
|
950
|
+
targetType: "behaviour",
|
|
951
|
+
targetLevel: targetMaturity,
|
|
952
|
+
priority: calculateBehaviourPriority(behaviour),
|
|
953
|
+
});
|
|
954
|
+
coveredBehaviours.add(behaviour.behaviourId);
|
|
955
|
+
totalMinutes += questionTime;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
job,
|
|
960
|
+
questions: selectedQuestions,
|
|
961
|
+
expectedDurationMinutes: totalMinutes,
|
|
962
|
+
coverage: {
|
|
963
|
+
skills: [],
|
|
964
|
+
behaviours: Array.from(coveredBehaviours),
|
|
965
|
+
capabilities: [],
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
}
|
package/{lib → src}/job-cache.js
RENAMED
|
@@ -11,13 +11,13 @@ import { deriveJob } from "./derivation.js";
|
|
|
11
11
|
const cache = new Map();
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Build a consistent cache key from job parameters
|
|
15
15
|
* @param {string} disciplineId
|
|
16
16
|
* @param {string} gradeId
|
|
17
17
|
* @param {string} [trackId] - Optional track ID
|
|
18
18
|
* @returns {string}
|
|
19
19
|
*/
|
|
20
|
-
export function
|
|
20
|
+
export function buildJobKey(disciplineId, gradeId, trackId = null) {
|
|
21
21
|
if (trackId) {
|
|
22
22
|
return `${disciplineId}_${gradeId}_${trackId}`;
|
|
23
23
|
}
|
|
@@ -43,7 +43,7 @@ export function getOrCreateJob({
|
|
|
43
43
|
behaviours,
|
|
44
44
|
capabilities,
|
|
45
45
|
}) {
|
|
46
|
-
const key =
|
|
46
|
+
const key = buildJobKey(discipline.id, grade.id, track?.id);
|
|
47
47
|
|
|
48
48
|
if (!cache.has(key)) {
|
|
49
49
|
const job = deriveJob({
|
|
@@ -66,7 +66,7 @@ export function getOrCreateJob({
|
|
|
66
66
|
/**
|
|
67
67
|
* Clear all cached jobs
|
|
68
68
|
*/
|
|
69
|
-
export function
|
|
69
|
+
export function clearCache() {
|
|
70
70
|
cache.clear();
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -76,14 +76,14 @@ export function clearJobCache() {
|
|
|
76
76
|
* @param {string} gradeId
|
|
77
77
|
* @param {string} [trackId] - Optional track ID
|
|
78
78
|
*/
|
|
79
|
-
export function
|
|
80
|
-
cache.delete(
|
|
79
|
+
export function invalidateCachedJob(disciplineId, gradeId, trackId = null) {
|
|
80
|
+
cache.delete(buildJobKey(disciplineId, gradeId, trackId));
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Get the number of cached jobs (for testing/debugging)
|
|
85
85
|
* @returns {number}
|
|
86
86
|
*/
|
|
87
|
-
export function
|
|
87
|
+
export function getCachedJobCount() {
|
|
88
88
|
return cache.size;
|
|
89
89
|
}
|