Files

449 lines
18 KiB
TypeScript

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();
}