Building a Job Classification System That Actually Understands What Companies Are Hiring For
When you’re building an automated job application system, the temptation is to treat all jobs the same. Parse the description, match some keywords, maybe calculate a score. But here’s the problem: a “Senior Software Engineer” role at a 10-person startup means something fundamentally different than the same title at a Fortune 500 company.
The startup wants someone who can ship features fast and tolerate ambiguity. The enterprise wants someone who can navigate compliance requirements and write documentation. If your resume emphasizes the wrong signals, you’re getting auto-rejected before a human even looks at it.
The Real Problem: Resume Tailoring Is Actually Hard
Most job application tools treat resume customization as a cosmetic problem. Change a few words here, swap out some bullet points there. But that’s not how hiring actually works.
Every company is hiring to solve a specific risk:
- Startups are afraid their product won’t get built fast enough
- Fintech companies are terrified of transaction failures
- SaaS platforms need someone who can scale infrastructure
- Enterprises want someone who won’t break existing systems
Your resume needs to signal that you understand their specific fear and know how to address it.
The Solution: Job Archetypes
Instead of treating every job posting as a unique snowflake, I built a classification system around seven hiring archetypes:
type JobArchetype =
| 'STARTUP_BUILDER' // Ship fast, iterate, wear many hats
| 'ENTERPRISE_ENGINEER' // Process, compliance, documentation
| 'SAAS_PLATFORM_ENGINEER' // Scale, reliability, multi-tenancy
| 'FINTECH_TRANSACTION_ENGINEER' // Correctness, security, audit trails
| 'FRONTEND_PRODUCT_ENGINEER' // UX, polish, user-facing features
| 'BACKEND_INFRA_ENGINEER' // Systems, performance, architecture
| 'FULLSTACK_GENERALIST' // Breadth over depth, context switchingEach archetype maps to a different resume weighting strategy:
interface ResumeWeighting {
emphasize: string[] // Skills/experiences to highlight
deEmphasize: string[] // Things to downplay or remove
toneBias: string // Overall vibe of the application
}For example, if the system classifies a job as STARTUP_BUILDER, it might return:
{
emphasize: [
"shipped products end-to-end",
"worked in small teams",
"made technical decisions independently"
],
deEmphasize: [
"committee-based decisions",
"long planning cycles"
],
toneBias: "ownership_and_speed"
}How Classification Actually Works
The classification happens through structured LLM output using function calling. Instead of asking the LLM to “analyze this job posting,” I give it a specific procedure:
const CLASSIFY_TOOL = {
type: 'function',
function: {
name: 'save_archetype_classification',
parameters: {
primary_archetype: {
type: 'string',
enum: ARCHETYPE_VALUES
},
secondary_archetype: {
type: 'string',
enum: [...ARCHETYPE_VALUES, 'NONE']
},
confidence: {
type: 'number',
description: 'Classification confidence from 0 to 100'
},
emphasize: {
type: 'array',
items: { type: 'string' }
},
// ... etc
}
}
}The system prompt instructs the LLM to score each archetype by counting signal matches:
- Trigger keyword match: +2 points
- Context signal match: +3 points
- Negative signal match: -2 points
This scoring approach keeps classifications consistent across runs and makes the reasoning auditable.
Research Phase: The Missing Piece
Here’s what I realized: job descriptions alone aren’t enough. A generic “full-stack engineer” posting could be any archetype depending on the company context.
So I added a research phase that runs between job discovery and classification:
export class ResearchJobUseCase {
async execute(jobId: string): Promise {
const job = await this.jobRepository.findById(jobId);
// Extract or resolve company website URL
const websiteUrl = await this.resolveCompanyUrl(job);
// Check if we already have recent research
let research = await this.researchRepo.findByWebsite(websiteUrl);
if (!research || this.isStale(research)) {
// Scrape company website and analyze
research = await this.llmProvider.researchCompany(websiteUrl);
await this.researchRepo.save(research);
}
// Now classify with company context
const archetype = await this.llmProvider.classifyArchetype(
job.description,
research.summary
);
job.classify(archetype);
await this.jobRepository.save(job);
}
}The company research extraction pulls things like:
- Tech stack mentioned on their site
- Recent product launches or news
- Company size and funding stage
- Culture values from their about page
This context dramatically improves classification accuracy. A job posting that just says “React engineer needed” becomes much clearer when you know the company is a Series A fintech startup that just raised $20M.
Model Selection: The Surprising Winner
I built a POC to compare three models on the classification task:
- GLM-4.7-Flash (cheap, fast)
- GLM-5 (expensive, supposedly better)
- Kimi K2.5 (middle ground)
const MODELS: ModelConfig[] = [
{
name: 'GLM-4.7-Flash',
inputCostPer1M: 0.07,
outputCostPer1M: 0.40,
},
{
name: 'GLM-5',
inputCostPer1M: 1.0,
outputCostPer1M: 3.2,
},
// ...
];GLM-4.7-Flash won. Not because it was marginally better at classification, but because the cost difference meant I could add the company research step without blowing the budget. Better context with a cheaper model beats marginally better reasoning with worse context.
Implementation Details That Matter
Separate LLM for classification: The classification model can be different from the resume parsing model. This lets you optimize cost vs. quality for each task:
this.classifierClient = new OpenAI({
apiKey: process.env.CLASSIFIER_API_KEY || process.env.LLM_API_KEY,
baseURL: process.env.CLASSIFIER_BASE_URL || process.env.LLM_BASE_URL,
});Status tracking: Jobs now move through a RESEARCHING status before classification. This makes the pipeline debuggable and lets you see where jobs get stuck.
Cached company research: Company data is stored in a separate table and reused across jobs. If you’ve researched Google once, you don’t need to do it again for every Google job posting.
Company URL resolution: Many job boards don’t include the company website. I added a resolver service that checks Redis cache first, then tries to extract it from the job description or company name.
What This Actually Enables
With jobs properly classified, the next step becomes possible: dynamically reweighting resume content based on the archetype.
Instead of one generic resume for everything, the system can now:
- Reorder bullet points to emphasize relevant experience
- Adjust language to match the company’s risk profile
- Skip or condense sections that don’t matter for this archetype
A financial services job gets your “implemented distributed transaction handling” experience front and center. A startup job leads with “shipped MVP in 3 weeks.”
Same experience, different story.
The Tradeoff
This approach adds latency and cost to the job processing pipeline. Every job now requires:
1. Company URL resolution (cached)
2. Company research (cached per company, not per job)
3. Archetype classification (per job)
But the alternative is sending the same resume to every company and hoping keyword matching works. It doesn’t. This is the cost of actually understanding what companies want.

Aniefon Umanah
Full Stack Engineer with 7+ years of experience. I write about software development, architecture patterns, and lessons learned from building real-world applications.