mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
init(app): Setup client package for web api specific logic
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
JobExtract,
|
||||
MatchResult,
|
||||
PlanExperience,
|
||||
PlanProject,
|
||||
PlanResponse,
|
||||
PlanSkillGroup,
|
||||
ResumePlan,
|
||||
} from "../shared/types";
|
||||
import { formatRange, type Corpus } from "./db";
|
||||
|
||||
const LIMITS = {
|
||||
skillGroups: 3,
|
||||
groupItems: 9,
|
||||
experiences: 2,
|
||||
expBullets: 3,
|
||||
projects: 3,
|
||||
projBullets: 3,
|
||||
bulletChars: 340,
|
||||
};
|
||||
|
||||
/** A rewrite thinner than this is a stub — fall back to the source bullet. */
|
||||
const MIN_BULLET_WORDS = 12;
|
||||
const MIN_BULLET_CHARS = 80;
|
||||
|
||||
const BULLET_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["i", "text"],
|
||||
properties: {
|
||||
i: { type: "integer", description: "Index of the source bullet this rewrite is based on" },
|
||||
text: {
|
||||
type: "string",
|
||||
description:
|
||||
"Rewrite of source bullet i: 18-30 words, keeps every number/metric and key technology from the source",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PLAN_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["title", "skills", "experience", "projects"],
|
||||
properties: {
|
||||
title: { type: "string", description: "Resume headline under the name, at most 6 words" },
|
||||
skills: {
|
||||
type: "array",
|
||||
maxItems: LIMITS.skillGroups,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["group", "items"],
|
||||
properties: {
|
||||
group: { type: "string" },
|
||||
items: { type: "array", maxItems: LIMITS.groupItems, items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
experience: {
|
||||
type: "array",
|
||||
maxItems: LIMITS.experiences,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["slug", "bullets"],
|
||||
properties: {
|
||||
slug: { type: "string" },
|
||||
bullets: { type: "array", maxItems: LIMITS.expBullets, items: BULLET_SCHEMA },
|
||||
},
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
type: "array",
|
||||
maxItems: LIMITS.projects,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["slug", "heading", "bullets"],
|
||||
properties: {
|
||||
slug: { type: "string" },
|
||||
heading: { type: "string", description: "Descriptive Title Case heading, at most 5 words" },
|
||||
bullets: { type: "array", maxItems: LIMITS.projBullets, items: BULLET_SCHEMA },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PLAN_SYSTEM = `You are an elite resume writer tailoring Prad Nukala's resume to a specific job posting. You receive the posting, a keyword match report, Prad's complete career materials (experience entries and projects addressed by slug, each with an indexed pool of factual source bullets), and optionally a conversation with Prad.
|
||||
|
||||
Return ONLY JSON matching the schema.
|
||||
|
||||
BULLET QUALITY BAR — the resume lives or dies here:
|
||||
- Every bullet must read like a senior engineer's resume line: strong verb + specific system/technology + scope + concrete outcome, 18-30 words.
|
||||
- Preserve EVERY metric and number from the source bullet ($4.7M, 120+, 1M+, 4th-highest, 6-engineer). Never drop or round them. Never invent new ones.
|
||||
- Keep the source bullet's specific technologies; cut only filler words.
|
||||
- Stub bullets are forbidden. "Founded Sonr", "Led fundraising", "Used Flutter framework" are automatic failures — any bullet under 15 words is rejected and replaced.
|
||||
- Each bullet object carries "i" (the [index] of the source bullet in the materials) and "text" (your rewrite of THAT bullet).
|
||||
|
||||
Example rewrite:
|
||||
source [0]: "Founded the startup and successfully led executive fundraising efforts, securing $4.7M in capital to build a peer-to-peer identity and asset management system leveraging decentralized identifiers (DIDs), WebAuthn, and IPFS."
|
||||
GOOD: {"i": 0, "text": "Founded Sonr and raised $4.7M to build a peer-to-peer identity and asset-management platform on decentralized identifiers (DIDs), WebAuthn, and IPFS."}
|
||||
BAD: {"i": 0, "text": "Founded Sonr and led fundraising."}
|
||||
|
||||
SELECTION:
|
||||
- "title": resume headline, at most 6 words, mirroring how the posting names the role.
|
||||
- "skills": exactly 3 groups; the first is "Languages". Name the other two using the posting's own vocabulary (e.g. "Protocols & Frameworks", "Infrastructure & Security"). 5-9 items per group, copied verbatim from the skill inventory — exact spelling, no additions.
|
||||
- "experience": 1-2 entries by slug, most relevant first; 3 bullets for the first, 2 for the second. Pick the source bullets whose facts best answer the posting's requirements.
|
||||
- "projects": 2-3 entries by slug; heading is a short descriptive Title Case name (e.g. "Layer-1 Blockchain & Identity"), never a slug; 2-3 bullets each.
|
||||
- One-page budget: at most 11 bullets total across experience and projects.
|
||||
- Honor explicit requests from the conversation with Prad.
|
||||
|
||||
OUTPUT SHAPE (follow EXACTLY — "skills" is an ARRAY of {group, items} objects, never a map):
|
||||
{"title":"Senior Platform Engineer","skills":[{"group":"Languages","items":["Go","Rust"]},{"group":"Infrastructure","items":["Kubernetes","Docker"]}],"experience":[{"slug":"sonr","bullets":[{"i":0,"text":"..."}]}],"projects":[{"slug":"appchain","heading":"Layer-1 Blockchain & Identity","bullets":[{"i":1,"text":"..."}]}]}`;
|
||||
|
||||
/** Extract assistant text from any Workers AI output shape (legacy, ChatCompletions, Responses). */
|
||||
function textOf(out: unknown): string {
|
||||
if (typeof out === "string") return out;
|
||||
if (out && typeof out === "object") {
|
||||
if ("response" in out) {
|
||||
const r: unknown = out.response;
|
||||
if (typeof r === "string") return r;
|
||||
// json_schema mode returns the parsed object directly in `response`.
|
||||
if (r && typeof r === "object") return JSON.stringify(r);
|
||||
}
|
||||
if ("choices" in out && Array.isArray(out.choices)) {
|
||||
const first: unknown = out.choices[0];
|
||||
if (first && typeof first === "object" && "message" in first) {
|
||||
const msg: unknown = first.message;
|
||||
if (msg && typeof msg === "object" && "content" in msg && typeof msg.content === "string") {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("output_text" in out && typeof out.output_text === "string") return out.output_text;
|
||||
}
|
||||
throw new Error("Workers AI returned an unexpected shape");
|
||||
}
|
||||
|
||||
/** Frontier model (ChatCompletions shape) used for resume plan generation. */
|
||||
async function runPlanModel(env: Env, system: string, user: string): Promise<string> {
|
||||
const messages: ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
];
|
||||
const out = await env.AI.run(env.PLAN_MODEL, {
|
||||
messages,
|
||||
max_tokens: 4096,
|
||||
temperature: 0.4,
|
||||
// Reasoning models otherwise think past the AI gateway timeout (504) on this prompt size.
|
||||
reasoning_effort: "low",
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: { name: "resume_plan", schema: PLAN_SCHEMA },
|
||||
},
|
||||
});
|
||||
return textOf(out);
|
||||
}
|
||||
|
||||
/** Fast model used for the interactive chat. */
|
||||
async function runChatModel(env: Env, system: string, history: ChatMessage[]): Promise<string> {
|
||||
const out = await env.AI.run(env.CHAT_MODEL, {
|
||||
messages: [{ role: "system", content: system }, ...history.slice(-12)],
|
||||
max_tokens: 640,
|
||||
temperature: 0.7,
|
||||
});
|
||||
return textOf(out);
|
||||
}
|
||||
|
||||
function materialsDigest(corpus: Corpus, match: MatchResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("SKILL INVENTORY");
|
||||
for (const category of ["language", "framework", "methodology"] as const) {
|
||||
const names = corpus.skills.filter((s) => s.category === category).map((s) => s.name);
|
||||
lines.push(`- ${category}: ${names.join(", ")}`);
|
||||
}
|
||||
|
||||
lines.push("", "EXPERIENCE ENTRIES");
|
||||
for (const e of corpus.experience) {
|
||||
lines.push(`slug=${e.slug} | ${e.title} @ ${e.company} | ${formatRange(e.start_date, e.end_date, "month")}`);
|
||||
e.bullets.forEach((b, i) => lines.push(` [${i}] ${b}`));
|
||||
}
|
||||
|
||||
const projectRank = new Map<string, number>();
|
||||
match.items.forEach((item, i) => {
|
||||
if (item.type === "project") projectRank.set(item.slug, i);
|
||||
});
|
||||
const ranked = [...corpus.projects].sort(
|
||||
(a, b) => (projectRank.get(a.slug) ?? 99) - (projectRank.get(b.slug) ?? 99),
|
||||
);
|
||||
lines.push("", "PROJECTS (most relevant first)");
|
||||
for (const p of ranked.slice(0, 9)) {
|
||||
lines.push(`slug=${p.slug} | ${p.title} | ${formatRange(p.start_date, p.end_date, "year")}`);
|
||||
p.bullets.forEach((b, i) => lines.push(` [${i}] ${b}`));
|
||||
}
|
||||
const rest = ranked.slice(9);
|
||||
if (rest.length > 0) {
|
||||
lines.push("", `OTHER PROJECT SLUGS: ${rest.map((p) => `${p.slug} (${p.title})`).join("; ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function matchDigest(match: MatchResult): string {
|
||||
const skills = match.skills.map((s) => `${s.name} x${s.hits}`).join(", ");
|
||||
const items = match.items
|
||||
.filter((i) => i.score > 0)
|
||||
.slice(0, 12)
|
||||
.map((i) => `${i.type}:${i.slug} score=${i.score} [${i.matchedSkills.join(", ")}]`)
|
||||
.join("\n");
|
||||
return `Matched skills: ${skills || "none"}\nRanked material:\n${items || "none"}`;
|
||||
}
|
||||
|
||||
function parseLooseJson(text: string): unknown {
|
||||
const stripped = text.replace(/```(?:json)?/gi, "");
|
||||
const start = stripped.indexOf("{");
|
||||
const end = stripped.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new Error("model output contained no JSON object");
|
||||
return JSON.parse(stripped.slice(start, end + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vet a rewritten bullet against its source. A stub (too short) or a rewrite
|
||||
* that lost the source's numbers is replaced by the source bullet verbatim —
|
||||
* the corpus bullets are professionally written, so verbatim is the floor.
|
||||
*/
|
||||
function vetBullet(rewrite: unknown, source: string | undefined): string | null {
|
||||
const text = typeof rewrite === "string" ? rewrite.trim() : "";
|
||||
const words = text.length > 0 ? text.split(/\s+/).length : 0;
|
||||
const tooThin = text.length < MIN_BULLET_CHARS || words < MIN_BULLET_WORDS;
|
||||
const lostNumbers = source !== undefined && /\d/.test(source) && !/\d/.test(text);
|
||||
if (!tooThin && !lostNumbers) return text.slice(0, LIMITS.bulletChars);
|
||||
if (source !== undefined) return source.slice(0, LIMITS.bulletChars);
|
||||
return words >= 8 ? text.slice(0, LIMITS.bulletChars) : null;
|
||||
}
|
||||
|
||||
/** Resolve `{i, text}` bullet entries against the item's source pool, vetting each rewrite. */
|
||||
function pickBullets(rawBullets: unknown, pool: string[], cap: number): string[] {
|
||||
if (!Array.isArray(rawBullets)) return [];
|
||||
const out: string[] = [];
|
||||
const usedSources = new Set<number>();
|
||||
for (const rb of rawBullets) {
|
||||
if (out.length >= cap) break;
|
||||
let idx = -1;
|
||||
let text: unknown = rb;
|
||||
if (rb && typeof rb === "object") {
|
||||
if ("i" in rb && typeof rb.i === "number") idx = Math.trunc(rb.i);
|
||||
if ("text" in rb) text = rb.text;
|
||||
}
|
||||
const source = idx >= 0 && idx < pool.length ? pool[idx] : undefined;
|
||||
if (source !== undefined) {
|
||||
if (usedSources.has(idx)) continue;
|
||||
usedSources.add(idx);
|
||||
}
|
||||
const vetted = vetBullet(text, source);
|
||||
if (vetted !== null && !out.includes(vetted)) out.push(vetted);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-proposed skill name onto the canonical corpus spelling.
|
||||
* Exact (case-insensitive) match first, then substring either way for
|
||||
* abbreviations like "AWS" -> "AWS Lambda", "Cryptography" -> "Cryptography (ZKP & MPC)".
|
||||
*/
|
||||
function resolveSkill(name: string, canon: Map<string, string>): string | null {
|
||||
const lower = name.trim().toLowerCase();
|
||||
if (lower.length < 2) return null;
|
||||
const exact = canon.get(lower);
|
||||
if (exact) return exact;
|
||||
if (lower.length < 3) return null;
|
||||
for (const [key, value] of canon) {
|
||||
if (key.includes(lower) || lower.includes(key)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Accept skills as the schema's array of {group, items} OR a loose {group: items[]} map. */
|
||||
function readSkillGroups(raw: unknown): { group: string; items: unknown[] }[] {
|
||||
if (Array.isArray(raw)) {
|
||||
const out: { group: string; items: unknown[] }[] = [];
|
||||
for (const g of raw) {
|
||||
if (g && typeof g === "object" && "group" in g && "items" in g && typeof g.group === "string" && Array.isArray(g.items)) {
|
||||
out.push({ group: g.group, items: g.items });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.entries(raw).flatMap(([group, items]) => (Array.isArray(items) ? [{ group, items }] : []));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function sanitizePlan(raw: unknown, corpus: Corpus): ResumePlan {
|
||||
if (!raw || typeof raw !== "object") throw new Error("plan is not an object");
|
||||
|
||||
const title =
|
||||
"title" in raw && typeof raw.title === "string" && raw.title.trim().length > 0
|
||||
? raw.title.trim().slice(0, 80)
|
||||
: "Software Engineer";
|
||||
|
||||
const canon = new Map<string, string>();
|
||||
for (const s of corpus.skills) canon.set(s.name.toLowerCase(), s.name);
|
||||
for (const item of [...corpus.experience, ...corpus.projects]) {
|
||||
for (const s of item.skills) if (!canon.has(s.toLowerCase())) canon.set(s.toLowerCase(), s);
|
||||
}
|
||||
|
||||
const skills: PlanSkillGroup[] = [];
|
||||
const usedSkills = new Set<string>();
|
||||
for (const g of ("skills" in raw ? readSkillGroups(raw.skills) : []).slice(0, LIMITS.skillGroups)) {
|
||||
const items: string[] = [];
|
||||
for (const x of g.items) {
|
||||
if (items.length >= LIMITS.groupItems || typeof x !== "string") continue;
|
||||
const resolved = resolveSkill(x, canon);
|
||||
if (resolved && !usedSkills.has(resolved)) {
|
||||
usedSkills.add(resolved);
|
||||
items.push(resolved);
|
||||
}
|
||||
}
|
||||
if (items.length >= 2) skills.push({ group: g.group.trim().slice(0, 40), items });
|
||||
}
|
||||
|
||||
const experience: PlanExperience[] = [];
|
||||
if ("experience" in raw && Array.isArray(raw.experience)) {
|
||||
for (const e of raw.experience.slice(0, LIMITS.experiences)) {
|
||||
if (!e || typeof e !== "object" || !("slug" in e) || !("bullets" in e)) continue;
|
||||
if (typeof e.slug !== "string") continue;
|
||||
const row = corpus.experience.find((x) => x.slug === e.slug);
|
||||
if (!row) continue;
|
||||
const cap = experience.length === 0 ? LIMITS.expBullets : 2;
|
||||
const bullets = pickBullets(e.bullets, row.bullets, cap);
|
||||
if (bullets.length > 0) experience.push({ slug: e.slug, bullets });
|
||||
}
|
||||
}
|
||||
|
||||
const projects: PlanProject[] = [];
|
||||
if ("projects" in raw && Array.isArray(raw.projects)) {
|
||||
for (const pr of raw.projects.slice(0, LIMITS.projects)) {
|
||||
if (!pr || typeof pr !== "object" || !("slug" in pr) || !("bullets" in pr)) continue;
|
||||
if (typeof pr.slug !== "string") continue;
|
||||
const row = corpus.projects.find((p) => p.slug === pr.slug);
|
||||
if (!row) continue;
|
||||
const bullets = pickBullets(pr.bullets, row.bullets, LIMITS.projBullets);
|
||||
const heading =
|
||||
"heading" in pr && typeof pr.heading === "string" && pr.heading.trim().length > 0
|
||||
? pr.heading.trim().slice(0, 48)
|
||||
: row.title.split(/[—|]/)[0].trim().slice(0, 48);
|
||||
if (bullets.length > 0) projects.push({ slug: pr.slug, heading, bullets });
|
||||
}
|
||||
}
|
||||
|
||||
if (experience.length === 0 || skills.length === 0) throw new Error("plan missing required sections");
|
||||
return { title, skills, experience, projects };
|
||||
}
|
||||
|
||||
/** Deterministic plan used when Workers AI is unavailable or returns garbage. */
|
||||
export function fallbackPlan(corpus: Corpus, match: MatchResult): ResumePlan {
|
||||
const matched = new Set(match.skills.map((s) => s.name));
|
||||
const pick = (category: "language" | "framework" | "methodology", count: number): string[] => {
|
||||
const all = corpus.skills.filter((s) => s.category === category);
|
||||
const preferred = all.filter((s) => matched.has(s.name));
|
||||
const rest = all.filter((s) => !matched.has(s.name) && (category !== "language" || s.tier === "expert"));
|
||||
return [...preferred, ...rest].slice(0, count).map((s) => s.name);
|
||||
};
|
||||
|
||||
const experience: PlanExperience[] = match.items
|
||||
.filter((i) => i.type === "experience")
|
||||
.slice(0, LIMITS.experiences)
|
||||
.map((i, idx) => ({ slug: i.slug, bullets: i.bullets.slice(0, idx === 0 ? 3 : 2) }));
|
||||
|
||||
const projects: PlanProject[] = match.items
|
||||
.filter((i) => i.type === "project" && i.score > 0)
|
||||
.slice(0, LIMITS.projects)
|
||||
.map((i) => ({
|
||||
slug: i.slug,
|
||||
heading: i.title.split(/[—|]/)[0].trim().slice(0, 48),
|
||||
bullets: i.bullets.slice(0, 2),
|
||||
}));
|
||||
|
||||
return {
|
||||
title: "Senior Software Engineer",
|
||||
skills: [
|
||||
{ group: "Languages", items: pick("language", 7) },
|
||||
{ group: "Frameworks", items: pick("framework", 8) },
|
||||
{ group: "Methodology", items: pick("methodology", 6) },
|
||||
],
|
||||
experience,
|
||||
projects,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlan(
|
||||
env: Env,
|
||||
corpus: Corpus,
|
||||
jd: JobExtract,
|
||||
match: MatchResult,
|
||||
chat: ChatMessage[],
|
||||
): Promise<PlanResponse> {
|
||||
const sections = [
|
||||
`JOB POSTING: ${jd.title}`,
|
||||
jd.text.slice(0, 6000),
|
||||
"",
|
||||
"MATCH REPORT",
|
||||
matchDigest(match),
|
||||
"",
|
||||
materialsDigest(corpus, match),
|
||||
];
|
||||
if (chat.length > 0) {
|
||||
sections.push("", "CONVERSATION WITH PRAD (honor explicit requests)");
|
||||
for (const m of chat.slice(-12)) sections.push(`${m.role}: ${m.content}`);
|
||||
}
|
||||
sections.push("", "Return the JSON plan now.");
|
||||
|
||||
try {
|
||||
const text = await runPlanModel(env, PLAN_SYSTEM, sections.join("\n"));
|
||||
return { plan: sanitizePlan(parseLooseJson(text), corpus), source: "ai" };
|
||||
} catch (err) {
|
||||
return {
|
||||
plan: fallbackPlan(corpus, match),
|
||||
source: "fallback",
|
||||
note: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function chatReply(
|
||||
env: Env,
|
||||
corpus: Corpus,
|
||||
jd: JobExtract,
|
||||
match: MatchResult,
|
||||
history: ChatMessage[],
|
||||
): Promise<string> {
|
||||
const system = `You are a resume-tailoring assistant helping Prad Nukala tailor his resume to a job posting. Be concise and concrete (2-5 sentences unless asked for more). You can discuss which experience, projects, skills, and phrasing to emphasize; the final resume is generated when Prad clicks Generate, and your conversation is taken into account. Never invent facts not present in the materials.
|
||||
|
||||
JOB POSTING: ${jd.title}
|
||||
${jd.text.slice(0, 3500)}
|
||||
|
||||
MATCH REPORT
|
||||
${matchDigest(match)}
|
||||
|
||||
${materialsDigest(corpus, match).slice(0, 6000)}`;
|
||||
|
||||
const text = await runChatModel(env, system, history);
|
||||
return text.trim();
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Row shapes and corpus loader for the cv-tailor D1 database. */
|
||||
|
||||
export interface ProfileRow {
|
||||
full_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
linkedin: string | null;
|
||||
github: string | null;
|
||||
twitter: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryRow {
|
||||
role: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ExperienceItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
company: string;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
location: string | null;
|
||||
profile_heading: string | null;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
export interface ProjectItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
url: string | null;
|
||||
associated_with: string | null;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
export interface SkillRow {
|
||||
name: string;
|
||||
category: "language" | "framework" | "methodology";
|
||||
grp: string | null;
|
||||
tier: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface EducationRow {
|
||||
school: string;
|
||||
degree: string | null;
|
||||
field: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
activities: string | null;
|
||||
}
|
||||
|
||||
export interface HonorRow {
|
||||
title: string;
|
||||
date: string | null;
|
||||
institution: string | null;
|
||||
}
|
||||
|
||||
export interface OrganizationRow {
|
||||
name: string;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface Corpus {
|
||||
profile: ProfileRow;
|
||||
summaries: SummaryRow[];
|
||||
experience: ExperienceItem[];
|
||||
projects: ProjectItem[];
|
||||
skills: SkillRow[];
|
||||
education: EducationRow | null;
|
||||
honors: HonorRow[];
|
||||
organizations: OrganizationRow[];
|
||||
}
|
||||
|
||||
interface BulletRow {
|
||||
parent_type: string;
|
||||
parent_slug: string;
|
||||
position: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ItemSkillRow {
|
||||
parent_type: string;
|
||||
parent_slug: string;
|
||||
skill: string;
|
||||
}
|
||||
|
||||
type ExperienceDbRow = Omit<ExperienceItem, "bullets" | "skills">;
|
||||
type ProjectDbRow = Omit<ProjectItem, "bullets" | "skills">;
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/**
|
||||
* "2021-03-01".."" -> "Mar 2021 – Present" (month style) or "2021 – Present" (year style).
|
||||
* Uses an en dash; the LaTeX escaper converts it to `--`.
|
||||
*/
|
||||
export function formatRange(
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
style: "month" | "year",
|
||||
): string {
|
||||
if (!start) return "";
|
||||
let from: string;
|
||||
let to = "Present";
|
||||
if (style === "month") {
|
||||
from = `${MONTHS[Number(start.slice(5, 7)) - 1]} ${start.slice(0, 4)}`;
|
||||
if (end) to = `${MONTHS[Number(end.slice(5, 7)) - 1]} ${end.slice(0, 4)}`;
|
||||
} else {
|
||||
from = start.slice(0, 4);
|
||||
if (end) to = end.slice(0, 4);
|
||||
}
|
||||
return `${from} – ${to}`;
|
||||
}
|
||||
|
||||
export async function loadCorpus(db: D1Database): Promise<Corpus> {
|
||||
const [profile, summaries, experience, projects, bullets, itemSkills, skills, education, honors, organizations] =
|
||||
await Promise.all([
|
||||
db.prepare("SELECT * FROM profile LIMIT 1").all<ProfileRow>(),
|
||||
db.prepare("SELECT role, summary FROM summaries").all<SummaryRow>(),
|
||||
db.prepare("SELECT * FROM experience ORDER BY start_date DESC").all<ExperienceDbRow>(),
|
||||
db.prepare("SELECT * FROM projects ORDER BY start_date DESC").all<ProjectDbRow>(),
|
||||
db
|
||||
.prepare("SELECT parent_type, parent_slug, position, text FROM bullets ORDER BY position")
|
||||
.all<BulletRow>(),
|
||||
db.prepare("SELECT parent_type, parent_slug, skill FROM item_skills").all<ItemSkillRow>(),
|
||||
db
|
||||
.prepare("SELECT name, category, grp, tier, position FROM skills ORDER BY position")
|
||||
.all<SkillRow>(),
|
||||
db.prepare("SELECT * FROM education LIMIT 1").all<EducationRow>(),
|
||||
db.prepare("SELECT title, date, institution FROM honors").all<HonorRow>(),
|
||||
db.prepare("SELECT name, url, title FROM organizations").all<OrganizationRow>(),
|
||||
]);
|
||||
|
||||
const profileRow = profile.results[0];
|
||||
if (!profileRow) throw new Error("D1 is empty — run `bun run sync` first.");
|
||||
|
||||
const experienceItems: ExperienceItem[] = experience.results.map((r) => ({ ...r, bullets: [], skills: [] }));
|
||||
const projectItems: ProjectItem[] = projects.results.map((r) => ({ ...r, bullets: [], skills: [] }));
|
||||
const bySlug = new Map<string, { bullets: string[]; skills: string[] }>();
|
||||
for (const item of experienceItems) bySlug.set(`experience:${item.slug}`, item);
|
||||
for (const item of projectItems) bySlug.set(`project:${item.slug}`, item);
|
||||
for (const b of bullets.results) bySlug.get(`${b.parent_type}:${b.parent_slug}`)?.bullets.push(b.text);
|
||||
for (const s of itemSkills.results) bySlug.get(`${s.parent_type}:${s.parent_slug}`)?.skills.push(s.skill);
|
||||
|
||||
return {
|
||||
profile: profileRow,
|
||||
summaries: summaries.results,
|
||||
experience: experienceItems,
|
||||
projects: projectItems,
|
||||
skills: skills.results,
|
||||
education: education.results[0] ?? null,
|
||||
honors: honors.results,
|
||||
organizations: organizations.results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { JobExtract } from "../shared/types";
|
||||
|
||||
const UA =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
||||
const MAX_BYTES = 4_000_000;
|
||||
const MAX_TEXT = 24_000;
|
||||
|
||||
/** Fetch a job posting URL and extract readable text, preferring JobPosting JSON-LD. */
|
||||
export async function extractJob(url: string): Promise<JobExtract> {
|
||||
const res = await fetch(url, {
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": UA,
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`upstream responded ${res.status} ${res.statusText}`);
|
||||
|
||||
const raw = await readCapped(res);
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("html")) {
|
||||
return { url, title: url, text: tidy(raw).slice(0, MAX_TEXT), source: "plain" };
|
||||
}
|
||||
|
||||
const posting = findJobPostingLd(raw);
|
||||
if (posting) return { url, title: posting.title, text: posting.text.slice(0, MAX_TEXT), source: "json-ld" };
|
||||
|
||||
const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(raw);
|
||||
const title = titleMatch ? tidy(decodeEntities(titleMatch[1])) : url;
|
||||
return { url, title: title || url, text: stripHtml(raw).slice(0, MAX_TEXT), source: "html" };
|
||||
}
|
||||
|
||||
/** Stream the body with a byte cap so an unbounded response cannot exhaust memory. */
|
||||
async function readCapped(res: Response): Promise<string> {
|
||||
if (!res.body) return "";
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.length;
|
||||
chunks.push(value);
|
||||
if (bytes >= MAX_BYTES) {
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const merged = new Uint8Array(Math.min(bytes, MAX_BYTES));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
const slice = chunk.subarray(0, Math.min(chunk.length, merged.length - offset));
|
||||
merged.set(slice, offset);
|
||||
offset += slice.length;
|
||||
if (offset >= merged.length) break;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: false, ignoreBOM: false }).decode(merged);
|
||||
}
|
||||
|
||||
/** Walk every <script type="application/ld+json"> looking for a schema.org JobPosting. */
|
||||
function findJobPostingLd(html: string): { title: string; text: string } | null {
|
||||
const scriptRe = /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
||||
for (let m = scriptRe.exec(html); m; m = scriptRe.exec(html)) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(m[1]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const posting = findPostingNode(parsed);
|
||||
if (!posting) continue;
|
||||
|
||||
const parts: string[] = [];
|
||||
const title = typeof posting.title === "string" ? tidy(posting.title) : "";
|
||||
if (title) parts.push(title);
|
||||
if (posting.hiringOrganization && typeof posting.hiringOrganization === "object") {
|
||||
const org = posting.hiringOrganization;
|
||||
if ("name" in org && typeof org.name === "string") parts.push(`Company: ${org.name}`);
|
||||
}
|
||||
if (typeof posting.description === "string") parts.push(stripHtml(posting.description));
|
||||
if (parts.length > (title ? 1 : 0)) return { title: title || "Job Posting", text: parts.join("\n\n") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface JobPostingNode {
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
hiringOrganization?: unknown;
|
||||
}
|
||||
|
||||
function findPostingNode(node: unknown): JobPostingNode | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findPostingNode(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!node || typeof node !== "object") return null;
|
||||
if ("@type" in node) {
|
||||
const t = node["@type"];
|
||||
const isPosting = t === "JobPosting" || (Array.isArray(t) && t.includes("JobPosting"));
|
||||
if (isPosting) {
|
||||
return {
|
||||
title: "title" in node ? node.title : undefined,
|
||||
description: "description" in node ? node.description : undefined,
|
||||
hiringOrganization: "hiringOrganization" in node ? node.hiringOrganization : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
if ("@graph" in node) return findPostingNode(node["@graph"]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strip an HTML document (or fragment) down to readable plain text. */
|
||||
export function stripHtml(html: string): string {
|
||||
let t = html
|
||||
.replace(/<!--[\s\S]*?-->/g, " ")
|
||||
.replace(/<(script|style|noscript|template|svg|head)\b[\s\S]*?<\/\1>/gi, " ")
|
||||
.replace(/<(br|\/p|\/div|\/li|\/tr|\/h[1-6]|\/section|\/article|\/ul|\/ol)[^>]*>/gi, "\n")
|
||||
.replace(/<li[^>]*>/gi, "\n- ")
|
||||
.replace(/<[^>]+>/g, " ");
|
||||
return tidy(decodeEntities(t));
|
||||
}
|
||||
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
ndash: "–",
|
||||
mdash: "—",
|
||||
bull: "•",
|
||||
hellip: "…",
|
||||
rsquo: "'",
|
||||
lsquo: "'",
|
||||
rdquo: '"',
|
||||
ldquo: '"',
|
||||
};
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => {
|
||||
if (code.startsWith("#x") || code.startsWith("#X"))
|
||||
return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
|
||||
if (code.startsWith("#")) return String.fromCodePoint(Number.parseInt(code.slice(1), 10));
|
||||
return NAMED_ENTITIES[code.toLowerCase()] ?? whole;
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize whitespace: collapse runs, trim lines, drop blank-line stacks. */
|
||||
function tidy(s: string): string {
|
||||
return s
|
||||
.replace(/\r/g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t\u00a0]+/g, " ").trim())
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ChatMessage, JobExtract, MatchResult, ResumePlan } from "../shared/types";
|
||||
import { buildPlan, chatReply } from "./ai";
|
||||
import { loadCorpus } from "./db";
|
||||
import { extractJob } from "./extract";
|
||||
import { renderLatex } from "./latex";
|
||||
import { matchCorpus } from "./match";
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error(JSON.stringify({ level: "error", path: c.req.path, message: err.message }));
|
||||
return c.json({ error: err.message }, 500);
|
||||
});
|
||||
|
||||
app.post("/api/extract", async (c) => {
|
||||
const { url } = await c.req.json<{ url?: string }>();
|
||||
const trimmed = url?.trim() ?? "";
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
return c.json({ error: "Enter a full http(s) URL to a job posting." }, 400);
|
||||
}
|
||||
let jd: JobExtract;
|
||||
try {
|
||||
jd = await extractJob(trimmed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Could not fetch the posting: ${message}` }, 502);
|
||||
}
|
||||
if (jd.text.length < 120) {
|
||||
return c.json(
|
||||
{ error: "The page yielded almost no text (likely rendered by JavaScript). Try the direct job-board URL (Greenhouse/Lever/Workable)." },
|
||||
422,
|
||||
);
|
||||
}
|
||||
return c.json(jd);
|
||||
});
|
||||
|
||||
app.post("/api/match", async (c) => {
|
||||
const { text } = await c.req.json<{ text?: string }>();
|
||||
if (!text) return c.json({ error: "Missing job text." }, 400);
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
return c.json(matchCorpus(corpus, text));
|
||||
});
|
||||
|
||||
app.post("/api/chat", async (c) => {
|
||||
const body = await c.req.json<{ jd: JobExtract; match: MatchResult; messages: ChatMessage[] }>();
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
try {
|
||||
const reply = await chatReply(c.env, corpus, body.jd, body.match, body.messages ?? []);
|
||||
return c.json({ reply });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: `Workers AI is unavailable (${message}). Check \`wrangler login\` / network.` }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/plan", async (c) => {
|
||||
const body = await c.req.json<{ jd: JobExtract; match?: MatchResult; chat?: ChatMessage[] }>();
|
||||
if (!body.jd?.text) return c.json({ error: "Missing job extract." }, 400);
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
const match = body.match ?? matchCorpus(corpus, body.jd.text);
|
||||
return c.json(await buildPlan(c.env, corpus, body.jd, match, body.chat ?? []));
|
||||
});
|
||||
|
||||
app.post("/api/latex", async (c) => {
|
||||
const { plan } = await c.req.json<{ plan?: ResumePlan }>();
|
||||
if (!plan) return c.json({ error: "Missing plan." }, 400);
|
||||
const corpus = await loadCorpus(c.env.DB);
|
||||
return c.json({ latex: renderLatex(corpus, plan) });
|
||||
});
|
||||
|
||||
/** Production fallback compiler; local dev uses the Vite /local/compile middleware (real pdflatex). */
|
||||
app.post("/api/pdf", async (c) => {
|
||||
const { latex } = await c.req.json<{ latex?: string }>();
|
||||
if (!latex) return c.json({ error: "Missing latex source." }, 400);
|
||||
const res = await fetch("https://latex.ytotech.com/builds/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ compiler: "pdflatex", resources: [{ main: true, content: latex }] }),
|
||||
});
|
||||
if (res.ok && res.body) {
|
||||
return new Response(res.body, { headers: { "Content-Type": "application/pdf" } });
|
||||
}
|
||||
const log = (await res.text()).slice(0, 4000);
|
||||
return c.json({ error: `Remote LaTeX service responded ${res.status}`, log }, 502);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ResumePlan } from "../shared/types";
|
||||
import { formatRange, type Corpus } from "./db";
|
||||
|
||||
/** Escape dynamic text for pdflatex (inputenc utf8 + fontenc T1, Computer Modern). */
|
||||
export function escapeLatex(s: string): string {
|
||||
let t = s.replace(/\\/g, "\u0000");
|
||||
t = t.replace(/([&%$#_{}])/g, "\\$1");
|
||||
t = t.replace(/~/g, "\\textasciitilde{}").replace(/\^/g, "\\textasciicircum{}");
|
||||
t = t.replace(/\u0000/g, "\\textbackslash{}");
|
||||
t = t
|
||||
.replace(/[\u2018\u2019\u02bc]/g, "'")
|
||||
.replace(/\u201c/g, "``")
|
||||
.replace(/\u201d/g, "''")
|
||||
.replace(/"([^"]*)"/g, "``$1''")
|
||||
.replace(/"/g, "''")
|
||||
.replace(/\u2014/g, "---")
|
||||
.replace(/[\u2012\u2013]/g, "--")
|
||||
.replace(/[\u2010\u2011\u2212]/g, "-")
|
||||
.replace(/\u00b7/g, "\\textperiodcentered{}")
|
||||
.replace(/[\u2022\u25aa\u25cf]/g, "\\textbullet{}")
|
||||
.replace(/\u2026/g, "\\ldots{}")
|
||||
.replace(/[\u00a0\u2007\u202f]/g, "~");
|
||||
return t.replace(/[^\x20-\x7e]/g, "");
|
||||
}
|
||||
|
||||
function itemize(bullets: string[]): string {
|
||||
const items = bullets.map((b) => ` \\item \\small{${escapeLatex(b)}}`).join("\n");
|
||||
return `\\begin{itemize}[leftmargin=*, nosep, topsep=2pt]\n${items}\n\\end{itemize}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the shared résumé template (same preamble and layout as ../src/*.tex)
|
||||
* with a tailored plan plus the role-independent corpus blocks.
|
||||
*/
|
||||
export function renderLatex(corpus: Corpus, plan: ResumePlan): string {
|
||||
const p = corpus.profile;
|
||||
|
||||
const contact: string[] = [];
|
||||
if (p.email) contact.push(`\\href{mailto:${p.email}}{${escapeLatex(p.email)}}`);
|
||||
if (p.phone) contact.push(escapeLatex(p.phone));
|
||||
if (p.website) contact.push(`\\href{${p.website}}{Website}`);
|
||||
if (p.linkedin) contact.push(`\\href{${p.linkedin}}{LinkedIn}`);
|
||||
if (p.github) contact.push(`\\href{${p.github}}{GitHub}`);
|
||||
if (p.twitter) contact.push(`\\href{${p.twitter}}{Twitter}`);
|
||||
|
||||
const skillGroups = plan.skills
|
||||
.map((g) => `\\textbf{${escapeLatex(g.group)}}\\\\[1pt]\n${escapeLatex(g.items.join(", "))}\\\\[5pt]`)
|
||||
.join("\n");
|
||||
|
||||
const honors = corpus.honors
|
||||
.map(
|
||||
(h) =>
|
||||
`\\textbf{${escapeLatex(h.title)}}\\\\[1pt]\n${escapeLatex(h.institution ?? "")} \\hfill \\textit{${h.date?.slice(0, 4) ?? ""}}\\\\[4pt]`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const organizations = corpus.organizations
|
||||
.map((o) => `\\textbf{${escapeLatex(o.name)}}\\\\[1pt]\n${escapeLatex(o.title ?? "")}\\\\[4pt]`)
|
||||
.join("\n");
|
||||
|
||||
const experience = plan.experience
|
||||
.flatMap((sel) => {
|
||||
const row = corpus.experience.find((e) => e.slug === sel.slug);
|
||||
if (!row || sel.bullets.length === 0) return [];
|
||||
const dates = escapeLatex(formatRange(row.start_date, row.end_date, "month"));
|
||||
const where = row.location ? `${escapeLatex(row.company)} --- ${escapeLatex(row.location)}` : escapeLatex(row.company);
|
||||
return [
|
||||
`\\textbf{${escapeLatex(row.title)}} \\hfill \\textit{${dates}}\\\\\n\\textit{${where}}\n${itemize(sel.bullets)}\n\\vspace{6pt}`,
|
||||
];
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const projects = plan.projects
|
||||
.flatMap((sel) => {
|
||||
const row = corpus.projects.find((pr) => pr.slug === sel.slug);
|
||||
if (!row || sel.bullets.length === 0) return [];
|
||||
const dates = escapeLatex(formatRange(row.start_date, row.end_date, "year"));
|
||||
return [`\\textbf{${escapeLatex(sel.heading)}} \\hfill \\textit{${dates}}\n${itemize(sel.bullets)}\n\\vspace{4pt}`];
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const edu = corpus.education;
|
||||
const education = edu
|
||||
? `\\textbf{B.S. in ${escapeLatex(edu.field ?? "")}} \\hfill \\textit{${escapeLatex(formatRange(edu.start_date, edu.end_date, "year"))}}\\\\\n\\textit{${escapeLatex(edu.school)}}`
|
||||
: "";
|
||||
|
||||
return `\\documentclass[11pt]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{geometry}
|
||||
\\usepackage{enumitem}
|
||||
\\usepackage{paracol}
|
||||
\\usepackage[hidelinks]{hyperref}
|
||||
\\geometry{a4paper, margin=0.5in}
|
||||
\\setlength{\\parindent}{0pt}
|
||||
\\setlength{\\parskip}{2pt}
|
||||
\\columnratio{0.3}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
{\\centering
|
||||
{\\Huge \\textbf{${escapeLatex(p.full_name)}}}\\\\[2pt]
|
||||
{\\large ${escapeLatex(plan.title)}}
|
||||
\\par
|
||||
}
|
||||
\\vspace{14pt}
|
||||
|
||||
\\begin{paracol}{2}
|
||||
\\raggedright
|
||||
\\small
|
||||
{\\bfseries Contact}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${contact.join("\\\\[2pt]\n")}
|
||||
|
||||
\\vspace{8pt}
|
||||
|
||||
{\\bfseries Skills}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${skillGroups}
|
||||
|
||||
\\vspace{8pt}
|
||||
|
||||
{\\bfseries Honors}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${honors}
|
||||
|
||||
\\vspace{8pt}
|
||||
|
||||
{\\bfseries Organizations}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${organizations}
|
||||
|
||||
\\switchcolumn
|
||||
\\normalsize
|
||||
\\raggedright
|
||||
{\\bfseries Experience}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${experience}
|
||||
|
||||
{\\bfseries Projects}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${projects}
|
||||
|
||||
{\\bfseries Education}\\\\[2pt]
|
||||
\\rule{\\linewidth}{0.4pt}\\\\[4pt]
|
||||
${education}
|
||||
\\vspace{4pt}
|
||||
|
||||
\\end{paracol}
|
||||
|
||||
\\end{document}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user