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();
|
||||
}
|
||||
Reference in New Issue
Block a user