openings 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.
Files changed (52) hide show
  1. package/.codex-plugin/plugin.json +23 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +110 -0
  5. package/data/companies.json +2550 -0
  6. package/docs/job-seeker-quickstart.md +109 -0
  7. package/package.json +42 -0
  8. package/skills/openings/SKILL.md +28 -0
  9. package/src/artifact-path.ts +48 -0
  10. package/src/atomic-file.ts +13 -0
  11. package/src/candidate-profile.ts +273 -0
  12. package/src/career-tracing.ts +181 -0
  13. package/src/catalog.ts +382 -0
  14. package/src/cli.ts +601 -0
  15. package/src/common-crawl-discovery.ts +137 -0
  16. package/src/company-seeds.ts +43 -0
  17. package/src/country-coverage.ts +203 -0
  18. package/src/crawl-reporting.ts +56 -0
  19. package/src/crawler.ts +146 -0
  20. package/src/enrichment-registry.ts +137 -0
  21. package/src/file-lock.ts +85 -0
  22. package/src/index.ts +37 -0
  23. package/src/intent-validation.ts +28 -0
  24. package/src/job-coverage.ts +73 -0
  25. package/src/job-fit-analysis.ts +247 -0
  26. package/src/job-matching.ts +445 -0
  27. package/src/job-recommendations.ts +193 -0
  28. package/src/job-search-preparation.ts +116 -0
  29. package/src/jobposting-probe.ts +167 -0
  30. package/src/local-jobs.ts +113 -0
  31. package/src/locations.ts +180 -0
  32. package/src/mcp.ts +98 -0
  33. package/src/package-mcp.ts +8 -0
  34. package/src/recruitee-round.ts +114 -0
  35. package/src/report-meta.ts +17 -0
  36. package/src/requirement-vocabulary.ts +111 -0
  37. package/src/resume-optimization.ts +118 -0
  38. package/src/runtime.ts +51 -0
  39. package/src/safe-get.ts +88 -0
  40. package/src/safe-head.ts +79 -0
  41. package/src/screening-requirements.ts +99 -0
  42. package/src/selected-job-lookup.ts +14 -0
  43. package/src/snapshot-catalog.ts +21 -0
  44. package/src/snapshot-export.ts +66 -0
  45. package/src/snapshot-store.ts +31 -0
  46. package/src/source-discovery-pipeline.ts +26 -0
  47. package/src/source-discovery.ts +231 -0
  48. package/src/source-enrichment.ts +92 -0
  49. package/src/source-pipeline.ts +245 -0
  50. package/src/source-verification.ts +297 -0
  51. package/src/tools.ts +194 -0
  52. package/src/types.ts +136 -0
@@ -0,0 +1,445 @@
1
+ import { validateCandidateProfileEvidence, type CandidateProfile } from "./candidate-profile.ts";
2
+ import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
3
+ import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
4
+ import type { Job } from "./types.ts";
5
+ import { evaluateScreeningRequirements, type ScreeningRequirement } from "./screening-requirements.ts";
6
+
7
+ export interface CandidateIntent {
8
+ roles?: string[];
9
+ countries?: string[];
10
+ locations?: string[];
11
+ remote?: boolean;
12
+ seniority?: string[];
13
+ requiredSkills?: string[];
14
+ excludedTerms?: string[];
15
+ excludedCountries?: string[];
16
+ excludedLocations?: string[];
17
+ excludedRoles?: string[];
18
+ }
19
+
20
+ export interface SupportedRequirement {
21
+ requirement: string;
22
+ factIds: string[];
23
+ }
24
+ export interface TransferableRequirement extends SupportedRequirement { via: TransferabilityKind }
25
+ export type RoleFamily = "backend" | "frontend" | "data" | "infrastructure";
26
+ export interface RoleFamilyExpansion {
27
+ family: RoleFamily;
28
+ aliases: string[];
29
+ derivedFrom: "explicit_intent" | "resume_evidence";
30
+ evidenceFactIds: string[];
31
+ }
32
+ export interface TitleExpansion {
33
+ family: RoleFamily;
34
+ alias: string;
35
+ derivedFrom: RoleFamilyExpansion["derivedFrom"];
36
+ evidenceFactIds: string[];
37
+ }
38
+
39
+ export interface JobMatch {
40
+ job: Job;
41
+ fit: "strong" | "good" | "stretch";
42
+ scores: { evidence: number; keyword: number };
43
+ selectedScore: number;
44
+ reasons: string[];
45
+ supported: SupportedRequirement[];
46
+ transferable: TransferableRequirement[];
47
+ gaps: string[];
48
+ discovery: { category: "direct" | "hidden" | "stretch"; titleExpansions: TitleExpansion[] };
49
+ }
50
+
51
+ export interface FilteredJob { jobId: string; reasons: string[] }
52
+ export interface JobMatchingResult {
53
+ matches: JobMatch[];
54
+ filteredOut: FilteredJob[];
55
+ assumptions: string[];
56
+ exploration: { roleFamilies: RoleFamilyExpansion[]; directMatches: JobMatch[]; hiddenMatches: JobMatch[]; stretchMatches: JobMatch[] };
57
+ }
58
+ export interface MatchingOptions { mode?: "evidence" | "keyword"; minimumPercent?: number }
59
+
60
+ export function matchJobs(profile: CandidateProfile, intent: CandidateIntent, jobs: Job[], limit = 20, options: MatchingOptions = {}): JobMatchingResult {
61
+ if (!validateCandidateProfileEvidence(profile).valid) throw new Error("invalid_candidate_profile");
62
+ const mode = options.mode ?? "evidence";
63
+ const roleFamilies = deriveRoleFamilies(profile, intent);
64
+ const filteredOut: FilteredJob[] = [];
65
+ const ranked: Array<JobMatch & { score: number; index: number }> = [];
66
+ for (const [index, job] of jobs.entries()) {
67
+ const rejectionReasons = hardFilterReasons(job, intent);
68
+ if (rejectionReasons.length) {
69
+ filteredOut.push({ jobId: job.id, reasons: rejectionReasons });
70
+ continue;
71
+ }
72
+ const jobRequirements = detectedRequirements(job);
73
+ const screeningRequirements = evaluateScreeningRequirements(profile, job);
74
+ const supported = supportedRequirements(profile, jobRequirements);
75
+ const transferable = transferableRequirements(profile, jobRequirements, supported);
76
+ const skillFocus = uniqueTerms(intent.requiredSkills ?? []).filter((skill) => includesPhrase(`${job.title}\n${job.description}`, skill));
77
+ const gapCandidates = [...jobRequirements, ...skillFocus, ...screeningRequirements.filter((item) => item.status !== "supported").map((item) => item.requirement)];
78
+ const gaps = [...new Set(gapCandidates)].filter((requirement) =>
79
+ !hasExplicitEvidence(profile, requirement) && !transferable.some((item) => equalSkill(item.requirement, requirement)),
80
+ );
81
+ const roleTargets = intent.roles?.length ? intent.roles : inferredRoleTargets(profile);
82
+ const titleExpansions = matchTitleExpansions(roleFamilies, job);
83
+ const role = roleAlignment(roleTargets, job.title, Boolean(intent.roles?.length), titleExpansions[0]);
84
+ const seniority = seniorityAlignment(profile, intent, job);
85
+ const screeningShortfalls = screeningRequirements.filter((item) => item.status !== "supported").length;
86
+ const score = role.score + supported.length * 2 + transferable.length + skillFocus.length * 3 + seniority.score - gaps.length * 2 - screeningShortfalls * 6;
87
+ const fit = screeningRequirements.some((item) => item.status !== "supported") ? "stretch" : classifyFit(score, gaps.length);
88
+ const scores = {
89
+ evidence: evidencePercent(role, roleTargets, seniority, supported, transferable, gaps, screeningRequirements),
90
+ keyword: keywordPercent(profile, job, screeningRequirements),
91
+ };
92
+ const selectedScore = scores[mode];
93
+ if (options.minimumPercent !== undefined && selectedScore < options.minimumPercent) {
94
+ filteredOut.push({ jobId: job.id, reasons: [`below_minimum_${mode}_score:${selectedScore}`] });
95
+ continue;
96
+ }
97
+ const reasons = [
98
+ ...(role.reason ? [role.reason] : []),
99
+ ...(seniority.reason ? [seniority.reason] : []),
100
+ ...supported.map((item) => `${item.requirement} is supported by resume evidence`),
101
+ ...transferable.map((item) => `${item.requirement} has related ${item.via} evidence but is not an explicit resume skill`),
102
+ ...skillFocus.map((skill) => `job matches requested skill focus: ${skill}`),
103
+ ];
104
+ const category = fit === "stretch" ? "stretch" : directTitleMatch(roleTargets, job.title) ? "direct" : titleExpansions.length ? "hidden" : "stretch";
105
+ ranked.push({
106
+ job,
107
+ fit,
108
+ scores,
109
+ selectedScore,
110
+ reasons: reasons.length ? reasons : ["no direct resume evidence matched; retained as a stretch option"],
111
+ supported,
112
+ transferable,
113
+ gaps,
114
+ discovery: { category, titleExpansions },
115
+ score,
116
+ index,
117
+ });
118
+ }
119
+ ranked.sort((left, right) => right.selectedScore - left.selectedScore || fitRank(right.fit) - fitRank(left.fit) || right.score - left.score || freshness(right.job) - freshness(left.job) || left.index - right.index);
120
+ const matches = ranked.slice(0, Math.max(0, limit)).map(({ score: _score, index: _index, ...match }) => match);
121
+ return {
122
+ matches,
123
+ filteredOut,
124
+ assumptions: [
125
+ ...(intent.countries?.length ? [] : ["No positive target country was requested"]),
126
+ ...(intent.roles?.length ? [] : [inferredRoleTargets(profile).length ? "No explicit role intent was supplied; resume role evidence guided ordering" : "No role intent or resume role evidence was available"]),
127
+ ...(typeof intent.remote === "boolean" ? [] : ["No work-mode preference was requested"]),
128
+ ],
129
+ exploration: {
130
+ roleFamilies,
131
+ directMatches: matches.filter((match) => match.discovery.category === "direct"),
132
+ hiddenMatches: matches.filter((match) => match.discovery.category === "hidden"),
133
+ stretchMatches: matches.filter((match) => match.discovery.category === "stretch"),
134
+ },
135
+ };
136
+ }
137
+
138
+ function roleAlignment(targets: string[], title: string, explicit: boolean, expansion?: TitleExpansion): { score: number; reason?: string } {
139
+ if (!targets.length) return { score: 0 };
140
+ if (!explicit) {
141
+ const matched = targets.some((target) => tokenOverlap(target, title) >= 0.5);
142
+ if (matched) return { score: 4, reason: "title aligns with resume role evidence" };
143
+ if (expansion) return { score: 3, reason: `title-family expansion surfaced ${expansion.alias} from ${expansion.family} evidence` };
144
+ return { score: 0 };
145
+ }
146
+ let best = targets.some((target) => includesPhrase(target, "backend")) ? -6 : 0;
147
+ let kind: "exact" | "adjacent" | "mismatch" = "mismatch";
148
+ for (const target of targets) {
149
+ const backendTarget = includesPhrase(target, "backend");
150
+ if (!backendTarget) {
151
+ if (tokenOverlap(target, title) >= 0.5 && best < 4) { best = 4; kind = "exact"; }
152
+ continue;
153
+ }
154
+ const conflictingSpecialist = /\b(?:product manager|qa|quality assurance|test|support|site reliability|sre|devops|front(?:end|-end)|data|machine learning|ai|ml)\b/i.test(title);
155
+ const compatibleEngineer = /\b(?:engineer|developer)\b/i.test(title);
156
+ if (!conflictingSpecialist && compatibleEngineer && (includesPhrase(title, target) || tokenOverlap(target, title) === 1 || includesPhrase(title, "backend"))) {
157
+ if (best < 6) { best = 6; kind = "exact"; }
158
+ continue;
159
+ }
160
+ const genericSoftware = /\b(?:software engineer|software developer|application developer|full[- ]stack developer)\b/i.test(title);
161
+ const adjacent = genericSoftware && !conflictingSpecialist;
162
+ if (adjacent && best < 3) { best = 3; kind = "adjacent"; }
163
+ }
164
+ if (kind === "exact") return { score: best, reason: explicit ? "title matches explicit role intent" : "title aligns with resume role evidence" };
165
+ if (kind === "adjacent") return { score: best, reason: explicit ? "title is adjacent to explicit role intent" : "title is adjacent to resume role evidence" };
166
+ if (expansion) return { score: 3, reason: `title-family expansion surfaced ${expansion.alias} from ${expansion.family} evidence` };
167
+ return { score: best };
168
+ }
169
+
170
+ const titleAliases: Record<RoleFamily, string[]> = {
171
+ backend: ["platform engineer", "api engineer", "api developer", "distributed systems engineer", "server-side engineer", "services engineer", "software engineer", "software developer", "application developer"],
172
+ frontend: ["ui engineer", "web engineer", "web developer", "client engineer", "software engineer"],
173
+ data: ["data engineer", "analytics engineer", "machine learning engineer", "ml engineer", "data platform engineer"],
174
+ infrastructure: ["infrastructure engineer", "cloud engineer", "devops engineer", "site reliability engineer", "sre", "platform engineer"],
175
+ };
176
+
177
+ function deriveRoleFamilies(profile: CandidateProfile, intent: CandidateIntent): RoleFamilyExpansion[] {
178
+ const explicit = new Set<RoleFamily>();
179
+ for (const role of intent.roles ?? []) {
180
+ if (/\b(?:backend|api|server-side|distributed systems)\b/iu.test(role)) explicit.add("backend");
181
+ if (/\b(?:frontend|front-end|ui|web)\b/iu.test(role)) explicit.add("frontend");
182
+ if (/\b(?:data|analytics|machine learning|ml)\b/iu.test(role)) explicit.add("data");
183
+ if (/\b(?:infrastructure|cloud|devops|site reliability|sre)\b/iu.test(role)) explicit.add("infrastructure");
184
+ if (includesPhrase(role, "platform engineer")) { explicit.add("backend"); explicit.add("infrastructure"); }
185
+ }
186
+ const inferred = new Map<RoleFamily, string[]>();
187
+ for (const inference of profile.inferences) if (inference.kind === "role_family") inferred.set(inference.value, inference.derivedFromFactIds);
188
+ const expansions: RoleFamilyExpansion[] = [];
189
+ for (const family of ["backend", "frontend", "data", "infrastructure"] as const) {
190
+ if (explicit.has(family)) { expansions.push({ family, aliases: titleAliases[family], derivedFrom: "explicit_intent", evidenceFactIds: [] }); continue; }
191
+ const evidenceFactIds = inferred.get(family);
192
+ if (evidenceFactIds) expansions.push({ family, aliases: titleAliases[family], derivedFrom: "resume_evidence", evidenceFactIds });
193
+ }
194
+ return expansions;
195
+ }
196
+
197
+ function matchTitleExpansions(families: RoleFamilyExpansion[], job: Job): TitleExpansion[] {
198
+ const matches = families.flatMap((family) => family.aliases
199
+ .filter((alias) => includesPhrase(job.title, alias) && aliasCorroborated(family.family, alias, job.description))
200
+ .map((alias) => ({ family: family.family, alias, derivedFrom: family.derivedFrom, evidenceFactIds: family.evidenceFactIds })));
201
+ return matches.sort((left, right) => Number(right.derivedFrom === "explicit_intent") - Number(left.derivedFrom === "explicit_intent")
202
+ || right.evidenceFactIds.length - left.evidenceFactIds.length || left.family.localeCompare(right.family) || left.alias.localeCompare(right.alias));
203
+ }
204
+
205
+ const genericAliases = new Set(["software engineer", "software developer"]);
206
+ const familySignals: Record<RoleFamily, string[]> = {
207
+ backend: ["backend", "api", "java", "golang", "python", "node.js", "spring", "microservices", "distributed systems"],
208
+ frontend: ["frontend", "front-end", "javascript", "typescript", "react", "angular", "vue", "css"],
209
+ data: ["data pipeline", "analytics", "machine learning", "spark", "airflow", "dbt", "snowflake"],
210
+ infrastructure: ["infrastructure", "cloud", "aws", "azure", "gcp", "kubernetes", "terraform", "site reliability", "devops"],
211
+ };
212
+ function aliasCorroborated(family: RoleFamily, alias: string, description: string): boolean {
213
+ return !genericAliases.has(alias) || familySignals[family].some((signal) => includesPhrase(description, signal));
214
+ }
215
+
216
+ function directTitleMatch(targets: string[], title: string): boolean {
217
+ return targets.some((target) => includesPhrase(title, target) || tokenOverlap(target, title) === 1);
218
+ }
219
+
220
+ function hardFilterReasons(job: Job, intent: CandidateIntent): string[] {
221
+ const reasons: string[] = [];
222
+ if (intent.countries?.length && !intent.countries.some((country) => isEligibleForCountry(job, country))) {
223
+ reasons.push(...intent.countries.map((country) => `country_not_eligible:${country.toUpperCase()}`));
224
+ }
225
+ for (const country of intent.excludedCountries ?? []) if (isEligibleForCountry(job, country)) reasons.push(`country_excluded:${country.toUpperCase()}`);
226
+ if (intent.locations?.length && !intent.locations.some((location) => normalizeLocation(job.location).includes(normalizeLocation(location)))) reasons.push("location_mismatch");
227
+ for (const location of intent.excludedLocations ?? []) if (normalizeLocation(job.location).includes(normalizeLocation(location))) reasons.push(`location_excluded:${location}`);
228
+ for (const role of intent.excludedRoles ?? []) if (includesPhrase(job.title, role) || tokenOverlap(role, job.title) === 1) reasons.push(`role_excluded:${role}`);
229
+ if (intent.remote === true && job.workMode !== "remote") reasons.push("remote_required");
230
+ if (intent.remote === false && (job.workMode === "remote" || job.workMode === "unknown")) reasons.push("non_remote_required");
231
+ const searchable = `${job.title}\n${job.company}\n${job.location}\n${job.description}`;
232
+ for (const term of intent.excludedTerms ?? []) if (includesPhrase(searchable, term)) reasons.push(`excluded_term:${term}`);
233
+ return reasons;
234
+ }
235
+
236
+ function supportedRequirements(profile: CandidateProfile, requirements: string[]): SupportedRequirement[] {
237
+ const grouped = new Map<string, SupportedRequirement>();
238
+ for (const requirement of requirements) {
239
+ const facts = explicitEvidence(profile, requirement);
240
+ if (facts.length) grouped.set(requirement.toLocaleLowerCase(), { requirement, factIds: facts.map((fact) => fact.id) });
241
+ }
242
+ return [...grouped.values()];
243
+ }
244
+
245
+ const seniorityTerms = ["manager", "principal", "staff", "lead", "senior", "mid", "junior", "intern"] as const;
246
+ function seniorityAlignment(profile: CandidateProfile, intent: CandidateIntent, job: Job): { score: number; reason?: string } {
247
+ const requested = intent.seniority?.map((value) => value.toLocaleLowerCase()) ?? profile.inferences.filter((inference) => inference.kind === "seniority").map((inference) => inference.value);
248
+ const jobSeniority = seniorityTerms.find((term) => includesPhrase(job.title, term));
249
+ if (!jobSeniority) return { score: 0 };
250
+ if (!requested.length) {
251
+ const years = profile.inferences.find((inference) => inference.kind === "approximate_experience_years")?.value;
252
+ if (typeof years !== "number") return { score: 0 };
253
+ const aligned = experienceAlignsWithSeniority(years, jobSeniority);
254
+ return { score: aligned ? 2 : -2, reason: aligned ? `seniority aligns with resume experience evidence: ${jobSeniority}` : `seniority differs from resume experience evidence: ${jobSeniority}` };
255
+ }
256
+ if (requested.includes(jobSeniority)) return { score: 2, reason: `${intent.seniority?.length ? "seniority matches explicit intent" : "seniority aligns with resume evidence"}: ${jobSeniority}` };
257
+ return { score: -2, reason: `seniority differs from ${intent.seniority?.length ? "explicit intent" : "resume evidence"}: ${jobSeniority}` };
258
+ }
259
+
260
+ function experienceAlignsWithSeniority(years: number, seniority: typeof seniorityTerms[number]): boolean {
261
+ if (seniority === "intern" || seniority === "junior") return years <= 2;
262
+ if (seniority === "mid") return years >= 2 && years <= 6;
263
+ const minimums: Partial<Record<typeof seniorityTerms[number], number>> = { senior: 5, lead: 6, staff: 7, principal: 8, manager: 6 };
264
+ return years >= (minimums[seniority] ?? 0);
265
+ }
266
+
267
+ function evidencePercent(
268
+ role: { score: number }, roleTargets: string[], seniority: { score: number }, supported: SupportedRequirement[], transferable: TransferableRequirement[], gaps: string[], screening: ScreeningRequirement[],
269
+ ): number {
270
+ const requirements = new Set([...supported, ...transferable].map((item) => item.requirement.toLocaleLowerCase()));
271
+ for (const gap of gaps) requirements.add(gap.toLocaleLowerCase());
272
+ const rolePoints = !roleTargets.length ? 0 : role.score >= 4 ? 30 : role.score >= 3 ? 22.5 : 0;
273
+ const requirementPoints = requirements.size
274
+ ? ((supported.length + transferable.length * 0.5) / requirements.size) * 50
275
+ : 0;
276
+ const screeningPoints = screening.length
277
+ ? ((screening.filter((item) => item.status === "supported").length + screening.filter((item) => item.status === "partial").length * 0.5) / screening.length) * 20
278
+ : seniority.score > 0 ? 20 : seniority.score < 0 ? 0 : 10;
279
+ const raw = Math.round(rolePoints + requirementPoints + screeningPoints);
280
+ return screening.some((item) => item.status !== "supported") ? Math.min(raw, 79) : Math.min(raw, 100);
281
+ }
282
+
283
+ function keywordPercent(profile: CandidateProfile, job: Job, screening: ScreeningRequirement[]): number {
284
+ const searchable = profile.normalizedResume.text;
285
+ const titleKeywords = (job.title.toLocaleLowerCase().match(/[a-z0-9+#.]+/g) ?? [])
286
+ .filter((token) => token.length > 2 && !keywordStopWords.has(token));
287
+ const catalogKeywords = detectRequirementTerms(job.description);
288
+ const screeningKeywords = screening.map((item) => item.requirement);
289
+ const keywords = uniqueTerms([...titleKeywords, ...catalogKeywords, ...screeningKeywords]);
290
+ if (!keywords.length) return 0;
291
+ const matched = keywords.filter((keyword) => includesPhrase(searchable, keyword) || hyphenatedPhrase(searchable, keyword)).length;
292
+ const raw = Math.round((matched / keywords.length) * 100);
293
+ return screening.some((item) => item.status !== "supported") ? Math.min(raw, 79) : raw;
294
+ }
295
+
296
+ const keywordStopWords = new Set(["engineer", "engineering", "developer", "development", "senior", "staff", "principal", "lead", "technology"]);
297
+
298
+ function detectedRequirements(job: Job): string[] {
299
+ const positive = /\b(?:required?|must|need(?:ed)?|minimum|proficien(?:t|cy)|experience (?:in|with))\b/i;
300
+ const negative = /\b(?:no|not|without|optional|nice to have|preferred)\b/i;
301
+ const clauses = nonOptionalSectionText(job.description).split(/[.!?\n;]+|\b(?:while|whereas|but)\b/i).flatMap((segment) =>
302
+ positive.test(segment) && negative.test(segment) ? splitMixedRequirementClauses(segment, positive, negative) : [segment],
303
+ );
304
+ const requirementText = [
305
+ clauses.filter((segment) => positive.test(segment) && !negative.test(segment)).join("\n"),
306
+ requiredSectionText(job.description),
307
+ ].join("\n");
308
+ return detectRequirementTerms(requirementText);
309
+ }
310
+
311
+ function requiredSectionText(value: string): string {
312
+ const lines = structuredJobLines(value);
313
+ let required = false;
314
+ const selected: string[] = [];
315
+ for (const line of lines) {
316
+ const section = sectionHeading(line);
317
+ if (section) {
318
+ required = section === "required";
319
+ continue;
320
+ }
321
+ if (required) selected.push(line);
322
+ }
323
+ return selected.join("\n");
324
+ }
325
+
326
+ function nonOptionalSectionText(value: string): string {
327
+ let optional = false;
328
+ const selected: string[] = [];
329
+ for (const line of structuredJobLines(value)) {
330
+ const section = sectionHeading(line);
331
+ if (section) {
332
+ optional = section === "optional";
333
+ continue;
334
+ }
335
+ if (!optional) selected.push(line);
336
+ }
337
+ return selected.join("\n");
338
+ }
339
+
340
+ function structuredJobLines(value: string): string[] {
341
+ return value
342
+ .replace(/<br\s*\/?>/gi, "\n")
343
+ .replace(/<\/(?:p|li|ul|ol|h[1-6])>/gi, "\n")
344
+ .replace(/<[^>]+>/g, " ")
345
+ .replace(/&nbsp;|&#160;|\u00a0/gi, " ")
346
+ .replace(/&#0*39;|&apos;/gi, "'")
347
+ .replace(/&amp;/gi, "&")
348
+ .split("\n")
349
+ .map((line) => line.trim().replace(/\s+/g, " "))
350
+ .filter(Boolean);
351
+ }
352
+
353
+ function sectionHeading(value: string): "required" | "optional" | "other" | undefined {
354
+ const heading = value.replace(/:$/, "").trim();
355
+ if (/^(?:what(?:'|’)s required|required qualifications?|requirements?|qualifications?|minimum qualifications?|essentials?|essential qualifications?|must haves?|your experience includes)$/i.test(heading)) return "required";
356
+ if (/^(?:preferred qualifications?|optional requirements?|nice to have)$/i.test(heading)) return "optional";
357
+ if (/^(?:what you(?:'|’)ll do|responsibilities|we take care of our people|benefits|about .+)$/i.test(heading)) return "other";
358
+ return undefined;
359
+ }
360
+
361
+ function hyphenatedPhrase(value: string, phrase: string): boolean {
362
+ if (phrase.trim().toLocaleLowerCase() === "go") return false;
363
+ return includesPhrase(value.replace(/-/g, " "), phrase.replace(/-/g, " "));
364
+ }
365
+
366
+ function splitMixedRequirementClauses(segment: string, positive: RegExp, negative: RegExp): string[] {
367
+ const atoms = segment.split(/,|\band\b/i).map((atom) => atom.trim()).filter(Boolean);
368
+ const polarity = atoms.map((atom) => negative.test(atom) ? "negative" : positive.test(atom) ? "positive" : undefined);
369
+ for (const [index, value] of polarity.entries()) {
370
+ if (value) continue;
371
+ polarity[index] = polarity.slice(index + 1).find(Boolean) ?? polarity.slice(0, index).reverse().find(Boolean);
372
+ }
373
+ const groups: string[] = [];
374
+ for (const [index, atom] of atoms.entries()) {
375
+ if (index > 0 && polarity[index] === polarity[index - 1]) groups[groups.length - 1] += ` ${atom}`;
376
+ else groups.push(atom);
377
+ }
378
+ return groups;
379
+ }
380
+
381
+ function hasExplicitEvidence(profile: CandidateProfile, requirement: string): boolean {
382
+ return explicitEvidence(profile, requirement).length > 0;
383
+ }
384
+
385
+ function explicitEvidence(profile: CandidateProfile, requirement: string): CandidateProfile["facts"] {
386
+ return profile.facts.filter((fact) => requiresExactSkillEvidence(requirement)
387
+ ? fact.kind === "skill" && matchesExactSkillEvidence(requirement, fact.value)
388
+ : fact.kind !== "certification" && includesPhrase(fact.value, requirement));
389
+ }
390
+
391
+ function inferredRoleTargets(profile: CandidateProfile): string[] {
392
+ const roles = profile.facts.filter((fact) => fact.kind === "role").map((fact) => fact.value);
393
+ const families = profile.inferences.filter((inference) => inference.kind === "role_family").map((inference) => `${inference.value} engineer`);
394
+ return [...roles, ...families];
395
+ }
396
+
397
+ function transferableRequirements(profile: CandidateProfile, requirements: string[], supported: SupportedRequirement[]): TransferableRequirement[] {
398
+ const skills = profile.facts.filter((fact) => fact.kind === "skill");
399
+ return requirements.flatMap((requirement) => {
400
+ if (supported.some((item) => equalSkill(item.requirement, requirement))) return [];
401
+ const match = findTransferability(requirement, skills);
402
+ return match ? [{ requirement, ...match }] : [];
403
+ });
404
+ }
405
+
406
+ function equalSkill(left: string, right: string): boolean { return left.toLocaleLowerCase() === right.toLocaleLowerCase(); }
407
+ function classifyFit(score: number, gapCount: number): JobMatch["fit"] {
408
+ if (score >= 8 && gapCount === 0) return "strong";
409
+ if (score >= 4) return "good";
410
+ return "stretch";
411
+ }
412
+ function fitRank(fit: JobMatch["fit"]): number { return { strong: 3, good: 2, stretch: 1 }[fit]; }
413
+ function uniqueTerms(values: string[]): string[] {
414
+ const seen = new Set<string>();
415
+ return values.filter((value) => {
416
+ const key = value.trim().toLocaleLowerCase();
417
+ if (!key || seen.has(key)) return false;
418
+ seen.add(key);
419
+ return true;
420
+ });
421
+ }
422
+ function includesPhrase(value: string, phrase: string): boolean {
423
+ const normalizedPhrase = phrase.trim().toLocaleLowerCase();
424
+ if (!normalizedPhrase) return false;
425
+ if (normalizedPhrase === "go") {
426
+ const withoutProseIdioms = value.replace(/\b(?:go-to-market|go-live|on-the-go)\b/gi, " ");
427
+ return /(^|[^a-z0-9+#])Go(?=$|[^a-z0-9+#])/.test(withoutProseIdioms);
428
+ }
429
+ const escaped = normalizedPhrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
430
+ return new RegExp(`(^|[^a-z0-9+#])${escaped}(?=$|[^a-z0-9+#])`, "i").test(value);
431
+ }
432
+
433
+ function tokenOverlap(left: string, right: string): number {
434
+ const rawLeftTokens = left.toLocaleLowerCase().match(/[a-z0-9+#.]+/g) ?? [];
435
+ const distinctive = rawLeftTokens.filter((token) => !new Set(["engineer", "engineering", "developer", "development"]).has(token));
436
+ const leftTokens = new Set(distinctive.length ? distinctive : rawLeftTokens);
437
+ const rightTokens = new Set(right.toLocaleLowerCase().match(/[a-z0-9+#.]+/g) ?? []);
438
+ if (!leftTokens.size) return 0;
439
+ return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
440
+ }
441
+
442
+ function freshness(job: Job): number {
443
+ const timestamp = job.updatedAt ? Date.parse(job.updatedAt) : Number.NaN;
444
+ return Number.isFinite(timestamp) ? timestamp : 0;
445
+ }
@@ -0,0 +1,193 @@
1
+ import { parseCandidateProfile, type CandidateProfile, type ResumeInput } from "./candidate-profile.ts";
2
+ import type { SnapshotStore } from "./crawler.ts";
3
+ import { matchJobs, type CandidateIntent, type FilteredJob, type JobMatch, type MatchingOptions } from "./job-matching.ts";
4
+ import { isEligibleForCountry } from "./locations.ts";
5
+ import type { Company, CrawlReport, JobSnapshot } from "./types.ts";
6
+ import { snapshotStatus, type CrawlScope, type SnapshotStatus } from "./local-jobs.ts";
7
+ import { assertKnownKeys, isRecord, validateCandidateIntent } from "./intent-validation.ts";
8
+ import { projectJobCoverage, type JobCoverageSummary } from "./job-coverage.ts";
9
+
10
+ export type RefreshPolicy = "auto" | "never" | "always";
11
+ export interface RecommendationRefreshInput { policy?: RefreshPolicy; minimumMatches?: number; staleDays?: number }
12
+ export interface RecommendJobsInput { resume: ResumeInput; intent: CandidateIntent; ranking?: MatchingOptions; refresh?: RecommendationRefreshInput; limit?: number }
13
+ export interface RecommendationRefreshResult {
14
+ policy: RefreshPolicy;
15
+ attempted: boolean;
16
+ occurred: boolean;
17
+ reason: "policy_never" | "policy_always" | "snapshot_missing" | "snapshot_stale" | "insufficient_matches" | "not_needed";
18
+ failures: CrawlReport["failed"];
19
+ error?: { code: "refresh_failed"; message: string };
20
+ }
21
+ export interface RecommendJobsResult {
22
+ profile: CandidateProfile;
23
+ matches: JobMatch[];
24
+ exploration: ReturnType<typeof matchJobs>["exploration"];
25
+ filteredOut: FilteredJob[];
26
+ assumptions: string[];
27
+ ranking: { mode: "evidence" | "keyword"; minimumPercent: number };
28
+ snapshot: SnapshotStatus;
29
+ coverage: JobCoverageSummary;
30
+ refresh: RecommendationRefreshResult;
31
+ shortfall?: { minimumMatches: number; actualMatches: number; message: string };
32
+ nextActions: string[];
33
+ }
34
+
35
+ export interface JobRecommenderOptions {
36
+ sources: Company[];
37
+ store: SnapshotStore;
38
+ crawl(scope?: CrawlScope): Promise<CrawlReport>;
39
+ now?: () => Date;
40
+ }
41
+
42
+ export class RecommendationError extends Error {
43
+ constructor(readonly code: "snapshot_unavailable" | "invalid_recommendation_input", message: string, readonly field?: string) { super(message); }
44
+ }
45
+
46
+ export function createJobRecommender(options: JobRecommenderOptions) {
47
+ const now = options.now ?? (() => new Date());
48
+ return {
49
+ async recommend(value: unknown): Promise<RecommendJobsResult> {
50
+ const input = validateRecommendationInput(value);
51
+ const policy = input.refresh?.policy ?? "auto";
52
+ const staleDays = input.refresh?.staleDays ?? 14;
53
+ const minimumMatches = input.refresh?.minimumMatches ?? 5;
54
+ const profile = parseCandidateProfile(input.resume);
55
+ let snapshot = await options.store.read();
56
+ const fallbackSnapshot = snapshot;
57
+ const fallbackRevision = snapshotRevision(snapshot);
58
+ let matching = snapshot ? matchSnapshot(profile, input.intent, snapshot, input.ranking) : undefined;
59
+ const fallbackMatching = matching;
60
+ const relevant = snapshot ? relevantSnapshot(snapshot, input.intent, options.sources) : null;
61
+ const reason = refreshReason(policy, relevant, matching ? preCutoffMatchCount(matching) : 0, minimumMatches, staleDays, now());
62
+ let report: CrawlReport | undefined;
63
+ let refreshError: RecommendationRefreshResult["error"];
64
+ const attempted = reason !== "policy_never" && reason !== "not_needed";
65
+ if (reason !== "policy_never" && reason !== "not_needed") {
66
+ try {
67
+ report = await options.crawl(refreshScope(input.intent, fallbackSnapshot, options.sources));
68
+ const refreshedSnapshot = await options.store.read();
69
+ if (!refreshedSnapshot) throw new Error("Refresh completed without producing a snapshot");
70
+ snapshot = refreshedSnapshot;
71
+ matching = matchSnapshot(profile, input.intent, snapshot, input.ranking);
72
+ } catch (error) {
73
+ refreshError = { code: "refresh_failed", message: error instanceof Error ? error.message : String(error) };
74
+ try {
75
+ snapshot = await options.store.read() ?? fallbackSnapshot;
76
+ matching = snapshot ? matchSnapshot(profile, input.intent, snapshot, input.ranking) : fallbackMatching;
77
+ } catch {
78
+ snapshot = fallbackSnapshot;
79
+ matching = fallbackMatching;
80
+ }
81
+ }
82
+ }
83
+ if (!snapshot || !matching) throw new RecommendationError("snapshot_unavailable", "No local job snapshot is available");
84
+ const shortfall = matching.matches.length < minimumMatches ? {
85
+ minimumMatches,
86
+ actualMatches: matching.matches.length,
87
+ message: `Found ${matching.matches.length} qualifying jobs after applying the requested constraints; ${minimumMatches} were requested`,
88
+ } : undefined;
89
+ const limited = limitMatching(matching, input.limit);
90
+ const applied = snapshotRevision(snapshot) !== fallbackRevision;
91
+ const occurred = report ? report.succeeded > 0 : applied;
92
+ return {
93
+ profile,
94
+ ...limited,
95
+ ranking: { mode: input.ranking?.mode ?? "evidence", minimumPercent: input.ranking?.minimumPercent ?? 0 },
96
+ snapshot: snapshotStatus(relevantSnapshot(snapshot, input.intent, options.sources), staleDays, now(), occurred && applied),
97
+ coverage: projectJobCoverage(options.sources, snapshot, input.intent.countries ?? []),
98
+ refresh: { policy, attempted, occurred, reason, failures: report?.failed ?? [], ...(refreshError ? { error: refreshError } : {}) },
99
+ ...(shortfall ? { shortfall } : {}),
100
+ nextActions: limited.matches.length ? [`Analyze fit for job ${limited.matches[0]!.job.id}`] : ["Clarify or broaden explicit job intent"],
101
+ };
102
+ },
103
+ };
104
+ }
105
+
106
+ function validateRecommendationInput(value: unknown): RecommendJobsInput {
107
+ if (!isRecord(value)) throw invalidInput("input", "Recommendation input must be an object");
108
+ assertKnownKeys(value, ["resume", "intent", "ranking", "refresh", "limit"], "input", invalidInput);
109
+ if (!isRecord(value.resume)) throw invalidInput("resume", "Resume input must be an object");
110
+ assertKnownKeys(value.resume, ["content", "format"], "resume", invalidInput);
111
+ validateCandidateIntent(value.intent, invalidInput);
112
+ if (value.ranking !== undefined) {
113
+ if (!isRecord(value.ranking)) throw invalidInput("ranking", "Ranking settings must be an object");
114
+ assertKnownKeys(value.ranking, ["mode", "minimumPercent"], "ranking", invalidInput);
115
+ if (value.ranking.mode !== undefined && !["evidence", "keyword"].includes(value.ranking.mode as string)) throw invalidInput("ranking.mode", "Ranking mode must be evidence or keyword");
116
+ if (value.ranking.minimumPercent !== undefined && (typeof value.ranking.minimumPercent !== "number" || !Number.isFinite(value.ranking.minimumPercent) || value.ranking.minimumPercent < 0 || value.ranking.minimumPercent > 100)) throw invalidInput("ranking.minimumPercent", "ranking.minimumPercent must be between 0 and 100");
117
+ }
118
+ if (value.refresh !== undefined) {
119
+ if (!isRecord(value.refresh)) throw invalidInput("refresh", "Refresh settings must be an object");
120
+ assertKnownKeys(value.refresh, ["policy", "minimumMatches", "staleDays"], "refresh", invalidInput);
121
+ if (value.refresh.policy !== undefined && !["auto", "never", "always"].includes(value.refresh.policy as string)) throw invalidInput("refresh.policy", "Unknown refresh policy");
122
+ validateNonNegativeInteger(value.refresh.minimumMatches, "refresh.minimumMatches");
123
+ validateNonNegativeFinite(value.refresh.staleDays, "refresh.staleDays");
124
+ }
125
+ if (value.limit !== undefined && (!Number.isInteger(value.limit) || (value.limit as number) <= 0 || (value.limit as number) > 100)) throw invalidInput("limit", "limit must be an integer between 1 and 100");
126
+ return value as unknown as RecommendJobsInput;
127
+ }
128
+
129
+ function validateNonNegativeFinite(value: unknown, field: string): void {
130
+ if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) throw invalidInput(field, `${field} must be a non-negative finite number`);
131
+ }
132
+
133
+ function validateNonNegativeInteger(value: unknown, field: string): void {
134
+ if (value !== undefined && (!Number.isInteger(value) || (value as number) < 0)) throw invalidInput(field, `${field} must be a non-negative integer`);
135
+ }
136
+
137
+ function invalidInput(field: string, message: string): RecommendationError {
138
+ return new RecommendationError("invalid_recommendation_input", message, field);
139
+ }
140
+
141
+ function matchSnapshot(profile: CandidateProfile, intent: CandidateIntent, snapshot: JobSnapshot, ranking?: MatchingOptions) {
142
+ const jobs = Object.values(snapshot.partitions).flatMap((partition) => partition.jobs);
143
+ return matchJobs(profile, intent, jobs, jobs.length, ranking);
144
+ }
145
+
146
+ function limitMatching<T extends ReturnType<typeof matchSnapshot>>(matching: T, limit = 20): T {
147
+ const matches = matching.matches.slice(0, Math.max(0, limit));
148
+ const ids = new Set(matches.map((match) => match.job.id));
149
+ return { ...matching, matches, exploration: {
150
+ ...matching.exploration,
151
+ directMatches: matching.exploration.directMatches.filter((match) => ids.has(match.job.id)),
152
+ hiddenMatches: matching.exploration.hiddenMatches.filter((match) => ids.has(match.job.id)),
153
+ stretchMatches: matching.exploration.stretchMatches.filter((match) => ids.has(match.job.id)),
154
+ } };
155
+ }
156
+
157
+ function preCutoffMatchCount(matching: ReturnType<typeof matchSnapshot>): number {
158
+ return matching.matches.length + matching.filteredOut.filter((job) => job.reasons.some((reason) => reason.startsWith("below_minimum_"))).length;
159
+ }
160
+
161
+ function refreshReason(policy: RefreshPolicy, snapshot: JobSnapshot | null, matchCount: number, minimumMatches: number, staleDays: number, now: Date): RecommendationRefreshResult["reason"] {
162
+ if (policy === "never") return "policy_never";
163
+ if (policy === "always") return "policy_always";
164
+ if (!snapshot) return "snapshot_missing";
165
+ if (snapshotStatus(snapshot, staleDays, now, false).stale) return "snapshot_stale";
166
+ if (matchCount < minimumMatches) return "insufficient_matches";
167
+ return "not_needed";
168
+ }
169
+
170
+ function refreshScope(intent: CandidateIntent, snapshot: JobSnapshot | null, sources: Company[]): CrawlScope {
171
+ return intent.countries?.length ? { slugs: relevantSourceSlugs(snapshot, intent, sources) } : {};
172
+ }
173
+
174
+ function relevantSnapshot(snapshot: JobSnapshot, intent: CandidateIntent, sources: Company[]): JobSnapshot {
175
+ if (!intent.countries?.length) return snapshot;
176
+ const relevantSlugs = new Set(relevantSourceSlugs(snapshot, intent, sources));
177
+ return { ...snapshot, partitions: Object.fromEntries(Object.entries(snapshot.partitions).filter(([slug]) => relevantSlugs.has(slug))) };
178
+ }
179
+
180
+ function relevantSourceSlugs(snapshot: JobSnapshot | null, intent: CandidateIntent, sources: Company[]): string[] {
181
+ const countries = [...new Set((intent.countries ?? []).map((country) => country.toUpperCase()))];
182
+ const available = new Set(sources.map((source) => source.slug));
183
+ const relevant = new Set(sources.filter((source) => source.cohorts?.some((country) => countries.includes(country))).map((source) => source.slug));
184
+ for (const [slug, partition] of Object.entries(snapshot?.partitions ?? {})) {
185
+ if (available.has(slug) && partition.jobs.some((job) => countries.some((country) => isEligibleForCountry(job, country)))) relevant.add(slug);
186
+ }
187
+ return sources.map((source) => source.slug).filter((slug) => relevant.has(slug));
188
+ }
189
+
190
+ function snapshotRevision(snapshot: JobSnapshot | null): string {
191
+ if (!snapshot) return "missing";
192
+ return JSON.stringify([snapshot.updatedAt, Object.entries(snapshot.partitions).map(([slug, partition]) => [slug, partition.fetchedAt])]);
193
+ }