Back to writing

The Bug That Made Resumter Actually Useful

5 min read

The complaint that didn't make sense

Resumter is a resume analyser. You upload a resume, paste a job description, and it scores you against it with a Gemini call, an ATS score, missing keywords, strengths, weaknesses, the usual.

I built it, then I did what every developer does after finishing a feature: I ran it on my own resume to feel good about myself. My resume has a line that says "GitHub" and it's a hyperlink, styled in blue, underlined, pointing at my profile. Resumter came back and told me my resume was missing a GitHub link.

I stared at that for a solid minute. The word "GitHub" was right there. Hyperlinked. I'd clicked it myself while testing.


What the model was actually seeing

The bug wasn't in the model. It was in what I handed the model.

My upload pipeline pulled text out of the PDF using pdfjs-dist, and text extraction from a PDF does exactly what it says: it extracts text. A hyperlink in a PDF isn't text. It's an annotation object sitting in a completely different part of the file, invisible to anything that's just reading page content.

So the string that reached Gemini looked like:

...
GitHub
LinkedIn
Portfolio
...

Three words with nothing attached. No github.com/whoever, no indication these were ever clickable. From the model's side, "missing GitHub link" was the correct read of the text it got. The bug was that I'd been extracting a resume and throwing away half of what made it a resume.


Annotations are a separate walk

PDF.js gives you page content and link annotations through two different calls, and I hadn't realized I needed the second one at all.

// utils/parser.ts
const pdf = await pdfjs.getDocument({ data: buffer }).promise;
 
const pageProxies = await Promise.all(
    Array.from({ length: pdf.numPages }, (_, i) => pdf.getPage(i + 1))
);
 
// pass 1: the visible text
const textContents = await Promise.all(
    pageProxies.map(p => p.getTextContent())
);
const text = textContents
    .flatMap((p: any) => p.items.map((item: any) => item.str))
    .join(" ");
 
// pass 2: the links, which live somewhere else entirely
const annotations = await Promise.all(
    pageProxies.map(p => p.getAnnotations())
);
const links = annotations
    .flat()
    .filter((a: any) => a.subtype === "Link" && a.url)
    .map((a: any) => a.url as string);

Two arrays, from two different questions asked of the same page. getTextContent() answers "what does this page say." getAnnotations() answers "what can you click on this page." A resume genuinely needs both, and I'd only been asking the first question.


Stitching them back together

Once I had both, the fix was just making sure the link survives into whatever string actually gets sent to the model:

function appendLinks(text: string, links: string[]): string {
    const unique = [...new Set(links)];
    if (unique.length === 0) return text;
    return `${text}\n\n--- EMBEDDED LINKS ---\n${unique.join("\n")}`;
}

Nothing clever. Dedupe, tack a labeled section onto the end. The --- EMBEDDED LINKS --- marker matters more than it looks like it should, it gives the model a clean signal that this block is metadata, not resume content, so it doesn't try to read "https://github.com/pradeepsingh2025" as a line on someone's work history.

Then the analysis prompt was told explicitly what to do with that section: check it for LinkedIn, GitHub, and portfolio URLs before deciding anything is missing. Small instruction, but without it the model has a labeled block of raw links and no reason to connect them to the "online presence" part of its own scoring criteria.


DOCX had the same problem, differently shaped

I assumed fixing the PDF path meant I was done. I wasn't. DOCX resumes go through mammoth, which converts the document to HTML, and HTML at least keeps links inline as <a href> tags, so in theory this should've been easier.

In practice I was still stripping tags down to plain text before the link ever got a chance to matter:

if (file.name.endsWith(".docx")) {
    const buffer = await file.arrayBuffer();
    const { value: html } = await mammoth.convertToHtml({ arrayBuffer: buffer });
 
    const linkRegex = /<a\s[^>]*href=["']([^"']+)["'][^>]*>/gi;
    const links: string[] = [];
    let match: RegExpExecArray | null;
    while ((match = linkRegex.exec(html)) !== null) {
        links.push(match[1]);
    }
 
    const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
    return appendLinks(text, links);
}

Same shape as the PDF fix: pull the links out before the tags get stripped, then reattach them through the same appendLinks helper. I like that both formats funnel into one function at the end, the extraction is format-specific because PDFs and DOCX files are structured nothing alike, but the "make sure the model sees this" step is one piece of code, not two.


Why this was worth a whole afternoon

It's a small bug. Nobody filed an issue. I found it by accident, testing my own resume out of vanity more than diligence.

But it's the kind of bug that doesn't announce itself. The pipeline ran. No exception, no red text, no failed request. Gemini answered confidently, in valid JSON, matching the schema exactly:

// utils/analyseSchema.ts
export const analyseSchema = z.object({
  atsScore: z.number().min(0).max(100),
  missingKeywords: z.array(z.string()),
  strengths: z.array(z.string()),
  weaknesses: z.array(z.string()),
  overallFeedback: z.string(),
});

Everything about that response looked like success. The score was a number in range, the arrays were arrays of strings, overallFeedback was a complete sentence. If I hadn't personally known my resume has a GitHub link, I would have shipped this and never known the tool was working off an incomplete picture of every PDF it touched.

That's the part I keep coming back to. A wrong answer that looks exactly like a right answer is the hardest kind of bug to catch, because there's no error to point at. The only thing that caught it was checking the model's answer against something I already knew was true.


The actual lesson

"Extract text from the PDF" was never really the task. The task was "extract everything about this resume that a hiring process would consider," and a resume in 2026 carries meaning outside the words, a hyperlinked project name, a GitHub badge, a portfolio link in the header. Any parser that only reads the words on the page is quietly throwing part of the resume away before the AI even gets a turn.

I check this now with any document pipeline I touch: am I extracting the content, or am I extracting the text? They're the same question for a plain .txt file. They stop being the same question the moment the format has any structure at all, and PDFs and DOCX files are nothing but structure wearing a text costume.