Skip to content
Dylan Carter

Prospect

A co-op application tracker with an AI layer: it parses postings into structured fields, audits a resume against them, and adds a Waterloo mode for WaterlooWorks. This piece covers the scoping decision that kept it a tracker, and the ship gate that has stopped the next feature from being built.

Open the appRepo

Context

Co-op recruiting produces a pile of applications, each with a deadline, a stage, a posting, and whichever resume version went out. Prospect holds all of it in seven Postgres tables with row-level security on each, so one account cannot read another's rows at the database rather than in application code. Postgres triggers write the status history, which means a stage change gets timestamped whether the app remembers to log it or not.

The AI sits on top of that. Six server-side routes call the Anthropic API: parse a job description into structured fields, pull text out of a resume PDF, rank resume bullets against a posting, score a resume, audit it into a keyword gap table, and draft interview questions. The key stays on the server and every route checks the session before it reaches the model.

Waterloo mode switches on when the account email ends in @uwaterloo.ca, adding cycle filtering, drag-and-drop ranking, and countdowns against a WaterlooWorks calendar table. The tracker underneath works for any student at any school.

The decision that mattered

It stayed a good tracker instead of becoming a mediocre job assistant.

That reads like a slogan until you look at where the line falls. The build spec for the AI phase opens with three ground rules and they are all restrictions: never fabricate, keep a human in the loop, and do not regress the tracker. The AI is additive. If it destabilises the parts that already work, the AI is what gets cut.

The gap table shows the rule in code. Every keyword the model pulls out of a posting comes back marked present, surfaceable, or missing. Present demands a quote from the resume as evidence. Surfaceable demands naming a specific existing bullet that could be reframed, and the prompt blocks reusing one quote to justify several unrelated keywords. Missing is the honest answer, and the prompt says so: never infer experience that is not in the resume text, and mark it missing when unsure. Hand the route something that is not a resume and it returns not_a_resume rather than analysing a transcript as though it were one.

The roadmap turns that into a schedule. The phase splits into six tasks, and task four is a gate rather than a feature. It asks three questions about the output of the tasks before it: is the table specific enough to change a resume over, are the present marks honest or rubber-stamped, and are the missing marks real gaps or noise from posting boilerplate. The spec's instruction if the answer is bad is to fix the prompt before building task five, because a rewrite feature standing on a bad gap table produces confident garbage.

Task five is the bullet rewriter. It is the feature that would turn a tracker into an assistant.

What it cost

The rewriter is specced in detail, typed, given a reserved database column, and not built.

lib/resumeAnalysis.ts exports a Suggestion type with the original bullet, the proposed rewrite, and the reasoning behind it. The migration reserves a suggestions column with the comment null until Task 5. No route fills it and no screen reads it. The gate has not been passed, so the work stops at the table that tells you what is wrong and leaves fixing it to you.

The roadmap holds two more features behind the same line. A prescriptive next-action queue and an anonymous compensation-sharing layer both sit under planned, behind an engine that is still in progress.

Persisting the analysis was the smaller cost. Each run stores the model that produced it and a snapshot of the posting text it read, because a gap table means nothing six weeks later if the posting it was measured against has changed underneath it. That is a row per analysis rather than a cached value, and it exists so the result stays interpretable rather than available and meaningless.

One caveat on all of that. The gate has never been run. Task five is unbuilt because I have not reached it, and no inspection has rejected anything. The structure that would stop a dishonest rewrite feature exists on paper and has yet to hold anything back, which makes it a stated intention rather than a demonstrated one. I would rather say that than take credit for restraint I have not had to exercise.

What I'd do differently

I spent the design budget on the model's honesty and shipped a dashboard that was careless with arithmetic.

The analytics views came before the gap table, and each card worked out its own numbers. The headline count included applications that were bookmarked and never sent. Response rate and average compensation reported percentages without saying what they divided by. The funnel drew bars that summed to nothing in particular, because an application sitting at rejected had already passed through stages the chart also counted.

The fix routed every number through one computeAnalytics function with the definitions written down: terminal statuses floor at applied, screening tests count as screening rather than interviews, compensation formats once. The funnel became a furthest-stage histogram whose bars add up to the total by construction. A follow-up put the sample size on the face of every card, renamed the headline count to tracked with an applied figure beside it, and captioned the chart with the bookmarked applications it leaves out.

Both passes were doing the same job the gap table does. A number with no denominator makes the same promise a keyword marked present with no quote does, and neither one earns it. The difference is that I wrote the rule down for the model and left it implicit for the dashboard, so the model got audited and the arithmetic did not.

The rule I would carry into the next project: any number a user reads is a claim, and a claim needs its basis attached. I would apply it to the boring surfaces first, because those are the ones nobody thinks to interrogate.