From 851df8ff9d3ffa959ca22a7ea48822656189180d Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Fri, 22 May 2026 00:02:45 +0200 Subject: [PATCH] feat(cvg-159): vacancy-aware skill prioritiser + context-bag assemble() (thin slice) (#68) --- .../__tests__/skill-prioritiser.spec.ts | 78 ++++++++++++++++++- .../cv-template/cv-data-assembler.service.ts | 25 ++++-- .../modules/cv-template/cv-template.module.ts | 4 +- .../modules/cv-template/skill-prioritiser.ts | 68 +++++++++++++--- 4 files changed, 157 insertions(+), 18 deletions(-) diff --git a/packages/core/src/modules/cv-template/__tests__/skill-prioritiser.spec.ts b/packages/core/src/modules/cv-template/__tests__/skill-prioritiser.spec.ts index 7139f15..5657939 100644 --- a/packages/core/src/modules/cv-template/__tests__/skill-prioritiser.spec.ts +++ b/packages/core/src/modules/cv-template/__tests__/skill-prioritiser.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { FrequencySkillPrioritiser } from "../skill-prioritiser"; +import { + FrequencySkillPrioritiser, + VacancyAwareSkillPrioritiser, +} from "../skill-prioritiser"; describe("FrequencySkillPrioritiser", () => { const prioritiser = new FrequencySkillPrioritiser(); @@ -34,3 +37,76 @@ describe("FrequencySkillPrioritiser", () => { ); }); }); + +describe("VacancyAwareSkillPrioritiser", () => { + const prioritiser = new VacancyAwareSkillPrioritiser(); + const frequency = new FrequencySkillPrioritiser(); + const sources = [ + { skills: ["TypeScript", "Postgres"] }, + { skills: ["TypeScript", "Docker"] }, + { skills: ["TypeScript", "Postgres", "Python", "Airflow"] }, + ]; + + it("falls back to pure frequency when no vacancy context is provided", () => { + expect(prioritiser.prioritise(sources)).toEqual( + frequency.prioritise(sources), + ); + }); + + it("falls back to pure frequency when vacancySkills is empty", () => { + expect(prioritiser.prioritise(sources, { vacancySkills: [] })).toEqual( + frequency.prioritise(sources), + ); + }); + + it("falls back to pure frequency when vacancy skills do not match anything the candidate has", () => { + expect( + prioritiser.prioritise(sources, { vacancySkills: ["COBOL", "Fortran"] }), + ).toEqual(frequency.prioritise(sources)); + }); + + it("pulls vacancy-listed skills to the top, preserving frequency order amongst themselves", () => { + const result = prioritiser.prioritise(sources, { + vacancySkills: ["Python", "Airflow"], + }); + + expect(result.map((s) => s.name)).toEqual([ + // Python and Airflow both have score 1 - tied, fall back to alphabetical + "Airflow", + "Python", + // Rest in frequency order (then alphabetical on ties) + "TypeScript", + "Postgres", + "Docker", + ]); + }); + + it("matches vacancy skills case-insensitively", () => { + const result = prioritiser.prioritise(sources, { + vacancySkills: ["python", "TYPESCRIPT"], + }); + + expect(result.map((s) => s.name).slice(0, 2)).toEqual([ + // TypeScript outranks Python by frequency, comes first within the matched tier + "TypeScript", + "Python", + ]); + }); + + it("does not invent vacancy-only skills the candidate doesn't have", () => { + const result = prioritiser.prioritise(sources, { + vacancySkills: ["Kubernetes"], + }); + + expect(result.map((s) => s.name)).not.toContain("Kubernetes"); + }); + + it("preserves score breakdown - reorders but never re-weights", () => { + const result = prioritiser.prioritise(sources, { + vacancySkills: ["Python"], + }); + + expect(result.find((s) => s.name === "Python")?.score).toBe(1); + expect(result.find((s) => s.name === "TypeScript")?.score).toBe(3); + }); +}); diff --git a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts index 92eead6..c95e2c5 100644 --- a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts +++ b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts @@ -41,6 +41,17 @@ const computeDuration = (start: Date, end: Date | null, now: Date): string => { return parts.length === 0 ? "<1m" : parts.join(" "); }; +/** + * Optional signals that personalise CV assembly. Forward-extensible: future + * pipeline steps (description-tailoring, length-budget, company research, ...) + * land here as additional fields without changing the assemble() signature. + * See CVG-159 for the full pipeline design. + */ +export interface AssembleContext { + /** Vacancy this CV is being tailored to; drives vacancy-aware ranking. */ + vacancy?: { skills: ReadonlyArray<{ name: string }> }; +} + @Injectable() export class CVDataAssemblerService { constructor( @@ -51,7 +62,10 @@ export class CVDataAssemblerService { private readonly skillPrioritiser: SkillPrioritiser, ) {} - async assemble(cvId: string): Promise { + async assemble( + cvId: string, + context?: AssembleContext, + ): Promise { const cv = await this.prisma.cV.findUniqueOrThrow({ where: { id: cvId } }); const [profile, experiences, educations] = await Promise.all([ @@ -101,10 +115,11 @@ export class CVDataAssemblerService { skills: edu.skills.map((s) => s.name), })); - const allSkills = this.skillPrioritiser.prioritise([ - ...experienceItems, - ...educationItems, - ]); + const vacancySkills = context?.vacancy?.skills.map((s) => s.name); + const allSkills = this.skillPrioritiser.prioritise( + [...experienceItems, ...educationItems], + vacancySkills ? { vacancySkills } : {}, + ); return { cv: { title: cv.title, introduction: cv.introduction ?? null }, diff --git a/packages/core/src/modules/cv-template/cv-template.module.ts b/packages/core/src/modules/cv-template/cv-template.module.ts index 1c0f73f..f59b2e8 100644 --- a/packages/core/src/modules/cv-template/cv-template.module.ts +++ b/packages/core/src/modules/cv-template/cv-template.module.ts @@ -11,8 +11,8 @@ import { CVRendererService } from "./cv-renderer.service"; import { CVTemplatePolicy } from "./cv-template.policy"; import { CVTemplateService } from "./cv-template.service"; import { - FrequencySkillPrioritiser, SKILL_PRIORITISER, + VacancyAwareSkillPrioritiser, } from "./skill-prioritiser"; @Module({ @@ -30,7 +30,7 @@ import { CVTemplatePolicy, CVDataAssemblerService, CVRendererService, - { provide: SKILL_PRIORITISER, useClass: FrequencySkillPrioritiser }, + { provide: SKILL_PRIORITISER, useClass: VacancyAwareSkillPrioritiser }, ], exports: [CVTemplateService, CVService], }) diff --git a/packages/core/src/modules/cv-template/skill-prioritiser.ts b/packages/core/src/modules/cv-template/skill-prioritiser.ts index 53fe169..4789c01 100644 --- a/packages/core/src/modules/cv-template/skill-prioritiser.ts +++ b/packages/core/src/modules/cv-template/skill-prioritiser.ts @@ -6,26 +6,74 @@ export interface PrioritisedSkill { } export interface SkillSource { - skills: string[]; + skills: readonly string[]; +} + +/** + * Per-call signals that personalise ranking. Forward-extensible: new fields + * (job-description embedding, recency window, user-pinned skills, ...) land + * here as additional optional properties without forcing existing prioritisers + * to change. See CVG-159 for the full pipeline shape. + */ +export interface PrioritiseContext { + /** Names of skills declared by the vacancy this CV is being tailored to. */ + vacancySkills?: readonly string[]; } export const SKILL_PRIORITISER = Symbol("SKILL_PRIORITISER"); export interface SkillPrioritiser { - prioritise(sources: readonly SkillSource[]): PrioritisedSkill[]; + prioritise( + sources: readonly SkillSource[], + context?: PrioritiseContext, + ): PrioritisedSkill[]; } +const byScoreThenName = (a: PrioritisedSkill, b: PrioritisedSkill): number => + b.score - a.score || a.name.localeCompare(b.name); + +const tallySkills = (sources: readonly SkillSource[]): PrioritisedSkill[] => + Array.from( + sources + .flatMap((s) => s.skills) + .reduce( + (acc, name) => acc.set(name, (acc.get(name) ?? 0) + 1), + new Map(), + ), + ([name, score]) => ({ name, score }), + ); + @Injectable() export class FrequencySkillPrioritiser implements SkillPrioritiser { prioritise(sources: readonly SkillSource[]): PrioritisedSkill[] { - const counts = new Map(); - for (const source of sources) { - for (const name of source.skills) { - counts.set(name, (counts.get(name) ?? 0) + 1); - } - } - return Array.from(counts, ([name, score]) => ({ name, score })).toSorted( - (a, b) => b.score - a.score || a.name.localeCompare(b.name), + return tallySkills(sources).toSorted(byScoreThenName); + } +} + +/** + * Frequency-ordered, with a two-tier split when the candidate has at least one + * skill the vacancy lists. Vacancy-matched skills come first (ordered amongst + * themselves by frequency), then the remainder. Skills the vacancy asks for + * but the candidate doesn't have are not invented - we only reorder what's + * actually in `sources`. + */ +@Injectable() +export class VacancyAwareSkillPrioritiser implements SkillPrioritiser { + prioritise( + sources: readonly SkillSource[], + context?: PrioritiseContext, + ): PrioritisedSkill[] { + const all = tallySkills(sources); + const wanted = new Set( + (context?.vacancySkills ?? []).map((s) => s.toLowerCase()), ); + if (wanted.size === 0) { + return all.toSorted(byScoreThenName); + } + const isWanted = (s: PrioritisedSkill) => wanted.has(s.name.toLowerCase()); + return [ + ...all.filter(isWanted).toSorted(byScoreThenName), + ...all.filter((s) => !isWanted(s)).toSorted(byScoreThenName), + ]; } } -- 2.51.2