Files
cv/app/worker/match.ts
T

105 lines
4.0 KiB
TypeScript
Raw Normal View History

import type { MatchResult, MatchedSkill, RankedItem } from "../shared/types";
import { formatRange, type Corpus } from "./db";
/** Skill names that collide with common English words — match these case-sensitively. */
const CASE_SENSITIVE = new Set(["Go", "R", "C", "Swift", "Dart", "Unity", "Agile", "Matrix"]);
/** Hand-curated aliases beyond the automatic parenthetical/slash splits. */
const EXTRA_ALIASES: Record<string, string[]> = {
Go: ["Golang"],
Kubernetes: ["K8s"],
"Google Cloud": ["GCP"],
"Test-Driven Development": ["TDD"],
"Continuous Integration": ["CI/CD", "CI"],
"React.js": ["ReactJS"],
"Protocol Buffers": ["Protobufs", "Protobuf"],
Protobufs: ["Protocol Buffers", "Protobuf"],
"Matrix Protocol": ["Matrix"],
"Decentralized Identifiers (DIDs)": ["DID"],
"Cloudflare Workers": ["Cloudflare"],
PostgreSQL: ["Postgres"],
};
/** "WebAssembly (WASM)" -> ["WebAssembly (WASM)", "WebAssembly", "WASM"]; "HTML/CSS" -> [..., "HTML", "CSS"]. */
function aliasesFor(name: string): string[] {
const out = [name];
const paren = /^(.*?)\s*\((.*?)\)$/.exec(name);
if (paren) {
out.push(paren[1].trim());
for (const part of paren[2].split(/\s*(?:[&,/·]|and)\s*/)) if (part.trim()) out.push(part.trim());
} else if (name.includes("/") && name !== "CI/CD") {
for (const part of name.split("/")) if (part.trim()) out.push(part.trim());
}
for (const extra of EXTRA_ALIASES[name] ?? []) out.push(extra);
return [...new Set(out)];
}
function countHits(text: string, name: string): number {
let hits = 0;
for (const alias of aliasesFor(name)) {
if (alias.length < 2 && !CASE_SENSITIVE.has(alias)) continue;
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const flags = CASE_SENSITIVE.has(alias) ? "g" : "gi";
const re = new RegExp(`(?<![A-Za-z0-9#+])${escaped}(?![A-Za-z0-9#+])`, flags);
hits += text.match(re)?.length ?? 0;
}
return hits;
}
function rankItem(
type: RankedItem["type"],
slug: string,
title: string,
company: string | undefined,
dates: string,
bullets: string[],
itemSkills: string[],
hitsByName: Map<string, number>,
): RankedItem {
const matchedSkills = itemSkills.filter((s) => hitsByName.has(s));
let bulletHits = 0;
for (const bullet of bullets) {
const lower = bullet.toLowerCase();
for (const s of matchedSkills) {
const stem = s.replace(/\s*\(.*\)$/, "").toLowerCase();
if (lower.includes(stem)) bulletHits++;
}
}
const score = matchedSkills.length * 3 + Math.min(bulletHits, 6);
return { type, slug, title, company, dates, score, matchedSkills, bullets };
}
/** Deterministic skill-keyword match of the corpus against extracted job text. */
export function matchCorpus(corpus: Corpus, jdText: string): MatchResult {
const categories = new Map<string, MatchedSkill["category"]>();
for (const s of corpus.skills) categories.set(s.name, s.category);
const universe = new Set<string>(corpus.skills.map((s) => s.name));
for (const item of corpus.experience) for (const s of item.skills) universe.add(s);
for (const item of corpus.projects) for (const s of item.skills) universe.add(s);
const hitsByName = new Map<string, number>();
for (const name of universe) {
const hits = countHits(jdText, name);
if (hits > 0) hitsByName.set(name, hits);
}
const skills: MatchedSkill[] = [...hitsByName.entries()]
.map(([name, hits]) => ({ name, hits, category: categories.get(name) ?? ("domain" as const) }))
.sort((a, b) => b.hits - a.hits);
const items: RankedItem[] = [];
for (const e of corpus.experience) {
items.push(
rankItem("experience", e.slug, e.title, e.company, formatRange(e.start_date, e.end_date, "month"), e.bullets, e.skills, hitsByName),
);
}
for (const p of corpus.projects) {
items.push(
rankItem("project", p.slug, p.title, undefined, formatRange(p.start_date, p.end_date, "year"), p.bullets, p.skills, hitsByName),
);
}
items.sort((a, b) => b.score - a.score);
return { skills, items };
}