init(app): Setup client package for web api specific logic

This commit is contained in:
Prad Nukala
2026-07-02 17:33:29 -04:00
parent f56116a26b
commit bcfeea93c9
27 changed files with 3104 additions and 0 deletions
+448
View File
@@ -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();
}
+161
View File
@@ -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,
};
}
+163
View File
@@ -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();
}
+88
View File
@@ -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;
+153
View File
@@ -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}
`;
}
+104
View File
@@ -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 };
}