From f16790b015c25824ffd0594fdba2d03f1a165885 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 18:17:44 +0200 Subject: [PATCH 1/5] feat(schemas): add Zod contract package Shared, validated contract for CV Builder surfaces: Resume, JobDescription, Archetype, Issue, Claim, and EvalResult (with required rubric/archetype versions). Closes #47 --- packages/schemas/README.md | 17 +++ packages/schemas/package.json | 36 +++++++ .../schemas/src/__tests__/schemas.test.ts | 102 ++++++++++++++++++ packages/schemas/src/archetype.ts | 25 +++++ packages/schemas/src/evaluation.ts | 51 +++++++++ packages/schemas/src/index.ts | 37 +++++++ packages/schemas/src/job-description.ts | 11 ++ packages/schemas/src/resume.ts | 52 +++++++++ packages/schemas/tsconfig.json | 10 ++ packages/schemas/vitest.config.ts | 8 ++ pnpm-lock.yaml | 21 ++++ 11 files changed, 370 insertions(+) create mode 100644 packages/schemas/README.md create mode 100644 packages/schemas/package.json create mode 100644 packages/schemas/src/__tests__/schemas.test.ts create mode 100644 packages/schemas/src/archetype.ts create mode 100644 packages/schemas/src/evaluation.ts create mode 100644 packages/schemas/src/index.ts create mode 100644 packages/schemas/src/job-description.ts create mode 100644 packages/schemas/src/resume.ts create mode 100644 packages/schemas/tsconfig.json create mode 100644 packages/schemas/vitest.config.ts diff --git a/packages/schemas/README.md b/packages/schemas/README.md new file mode 100644 index 0000000..c0793fa --- /dev/null +++ b/packages/schemas/README.md @@ -0,0 +1,17 @@ +# @cv-builder/schemas + +[Zod](https://zod.dev) schemas shared across CV Builder surfaces. Schemas are +the source of truth; TypeScript types are inferred from them. + +Phase 1 requires that no LLM response is used without passing Zod validation, +and that every `EvalResult` carries a `rubricVersion` and `archetypeVersion`. + +```ts +import { EvalResultSchema, type EvalResult } from "@cv-builder/schemas"; + +const result: EvalResult = EvalResultSchema.parse(rawModelOutput); +``` + +Exports: `Resume` / `ResumeSource`, `JobDescription`, `Archetype`, +`EvaluationWeights`, `EvaluationDimension`, `Issue`, `Claim`, `EvalResult` +(each with its `*Schema`). Evaluation types only — tailoring is Phase 2. diff --git a/packages/schemas/package.json b/packages/schemas/package.json new file mode 100644 index 0000000..1ead620 --- /dev/null +++ b/packages/schemas/package.json @@ -0,0 +1,36 @@ +{ + "name": "@cv-builder/schemas", + "version": "0.1.0", + "description": "Zod schemas and inferred types — the validated contract shared across CV Builder", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "cv", + "resume", + "zod", + "schema", + "types" + ], + "license": "MIT" +} diff --git a/packages/schemas/src/__tests__/schemas.test.ts b/packages/schemas/src/__tests__/schemas.test.ts new file mode 100644 index 0000000..848f937 --- /dev/null +++ b/packages/schemas/src/__tests__/schemas.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + ArchetypeSchema, + EvalResultSchema, + ResumeSchema, +} from "../index.js"; + +const validEvalResult = { + rubricVersion: "1.0.0", + archetypeVersion: "1.0.0", + archetypeId: "software-engineer", + archetypeName: "Software Engineer", + score: 3.4, + dimensions: [ + { + name: "Shipped Evidence", + weight: 0.3, + score: 4, + maxScore: 5, + feedback: "Strong production work with named outcomes.", + }, + ], + strengths: ["Clear quantified impact"], + issues: [ + { + element: "Summary", + quote: "Passionate team player", + why: "Generic phrasing with no evidence.", + fix: "Replace with a concrete, measurable achievement.", + severity: "major", + }, + ], + claims: [ + { + text: "Scaled system to 1M users", + category: "metric", + supported: false, + reason: "No corroborating detail elsewhere in the resume.", + }, + ], + atsCompatible: true, +}; + +describe("EvalResultSchema", () => { + it("parses a well-formed result", () => { + expect(() => EvalResultSchema.parse(validEvalResult)).not.toThrow(); + }); + + it("rejects a malformed result", () => { + const bad = { ...validEvalResult, score: "high" }; + expect(EvalResultSchema.safeParse(bad).success).toBe(false); + }); + + it("requires rubricVersion and archetypeVersion", () => { + for (const field of ["rubricVersion", "archetypeVersion"]) { + const partial = { ...validEvalResult }; + delete (partial as Record)[field]; + const result = EvalResultSchema.safeParse(partial); + expect(result.success, `${field} should be required`).toBe(false); + } + }); + + it("rejects an out-of-range dimension score", () => { + const bad = { + ...validEvalResult, + dimensions: [{ ...validEvalResult.dimensions[0], score: 7 }], + }; + expect(EvalResultSchema.safeParse(bad).success).toBe(false); + }); +}); + +describe("ResumeSchema", () => { + it("applies array/object defaults from rawText alone", () => { + const resume = ResumeSchema.parse({ rawText: "Jane Doe — Engineer" }); + expect(resume.links).toEqual([]); + expect(resume.skills).toEqual([]); + expect(resume.contact).toEqual({}); + }); +}); + +describe("ArchetypeSchema", () => { + it("requires at least one keyword", () => { + const archetype = { + id: "software-engineer", + name: "Software Engineer", + description: "Builds and ships software", + keywords: [], + evaluationWeights: { + shippedEvidence: 0.3, + quantifiedImpact: 0.2, + toolingVisibility: 0.2, + atsCompatibility: 0.1, + keywordMatch: 0.1, + publicProof: 0.1, + }, + actionVerbs: ["Built"], + antiPatterns: ["familiar with"], + version: "1.0.0", + }; + expect(ArchetypeSchema.safeParse(archetype).success).toBe(false); + }); +}); diff --git a/packages/schemas/src/archetype.ts b/packages/schemas/src/archetype.ts new file mode 100644 index 0000000..b26a011 --- /dev/null +++ b/packages/schemas/src/archetype.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const EvaluationWeightsSchema = z.object({ + shippedEvidence: z.number().min(0).max(1), + quantifiedImpact: z.number().min(0).max(1), + toolingVisibility: z.number().min(0).max(1), + atsCompatibility: z.number().min(0).max(1), + keywordMatch: z.number().min(0).max(1), + publicProof: z.number().min(0).max(1), +}); +export type EvaluationWeights = z.infer; + +// Weights are expected to sum to ~1.0, but that's enforced in the intelligence +// layer so a half-edited archetype still parses here. +export const ArchetypeSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + keywords: z.array(z.string()).min(1), + evaluationWeights: EvaluationWeightsSchema, + actionVerbs: z.array(z.string()), + antiPatterns: z.array(z.string()), + version: z.string(), +}); +export type Archetype = z.infer; diff --git a/packages/schemas/src/evaluation.ts b/packages/schemas/src/evaluation.ts new file mode 100644 index 0000000..36b01ed --- /dev/null +++ b/packages/schemas/src/evaluation.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +export const EvaluationDimensionSchema = z.object({ + name: z.string(), + weight: z.number().min(0).max(1), + score: z.number().int().min(0).max(5), + maxScore: z.number().int().positive().default(5), + feedback: z.string(), +}); +export type EvaluationDimension = z.infer; + +export const IssueSchema = z.object({ + element: z.string(), + quote: z.string().optional(), + why: z.string(), + fix: z.string(), + severity: z.enum(["critical", "major", "minor"]), +}); +export type Issue = z.infer; + +export const ClaimSchema = z.object({ + text: z.string(), + category: z.enum([ + "tool", + "technology", + "metric", + "experience", + "education", + "other", + ]), + supported: z.boolean(), + reason: z.string(), +}); +export type Claim = z.infer; + +// Versions are required so old results stay reproducible when the rubric or an +// archetype changes later. +export const EvalResultSchema = z.object({ + rubricVersion: z.string(), + archetypeVersion: z.string(), + archetypeId: z.string(), + archetypeName: z.string(), + score: z.number().min(0).max(5), + dimensions: z.array(EvaluationDimensionSchema), + strengths: z.array(z.string()).default([]), + issues: z.array(IssueSchema).default([]), + claims: z.array(ClaimSchema).default([]), + atsCompatible: z.boolean(), + locale: z.string().optional(), +}); +export type EvalResult = z.infer; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts new file mode 100644 index 0000000..87b220e --- /dev/null +++ b/packages/schemas/src/index.ts @@ -0,0 +1,37 @@ +// Zod schemas are the source of truth; types are inferred from them. +// Phase 1 ships evaluation types only — tailoring/rewrite is Phase 2. + +export { + EvaluationWeightsSchema, + ArchetypeSchema, + type EvaluationWeights, + type Archetype, +} from "./archetype.js"; + +export { + ResumeSourceSchema, + ResumeLinkSchema, + ResumeContactSchema, + ResumeExperienceSchema, + ResumeEducationSchema, + ResumeSchema, + type ResumeSource, + type ResumeLink, + type ResumeContact, + type ResumeExperience, + type ResumeEducation, + type Resume, +} from "./resume.js"; + +export { JobDescriptionSchema, type JobDescription } from "./job-description.js"; + +export { + EvaluationDimensionSchema, + IssueSchema, + ClaimSchema, + EvalResultSchema, + type EvaluationDimension, + type Issue, + type Claim, + type EvalResult, +} from "./evaluation.js"; diff --git a/packages/schemas/src/job-description.ts b/packages/schemas/src/job-description.ts new file mode 100644 index 0000000..4def655 --- /dev/null +++ b/packages/schemas/src/job-description.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +// Phase 1 uses the JD only as keyword-match context; fit-scoring is Phase 2. +export const JobDescriptionSchema = z.object({ + content: z.string(), + url: z.string().optional(), + company: z.string().optional(), + title: z.string().optional(), + keywords: z.array(z.string()).default([]), +}); +export type JobDescription = z.infer; diff --git a/packages/schemas/src/resume.ts b/packages/schemas/src/resume.ts new file mode 100644 index 0000000..4482faa --- /dev/null +++ b/packages/schemas/src/resume.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +// Raw input as it arrives from a surface, before extraction. +export const ResumeSourceSchema = z.object({ + content: z.string(), + format: z.enum(["pdf", "markdown", "plaintext", "html"]), +}); +export type ResumeSource = z.infer; + +export const ResumeLinkSchema = z.object({ + type: z.enum(["github", "linkedin", "portfolio", "blog", "website", "other"]), + url: z.string(), +}); +export type ResumeLink = z.infer; + +export const ResumeContactSchema = z.object({ + email: z.string().optional(), + phone: z.string().optional(), + location: z.string().optional(), +}); +export type ResumeContact = z.infer; + +export const ResumeExperienceSchema = z.object({ + company: z.string(), + role: z.string(), + startDate: z.string().optional(), + endDate: z.string().optional(), + bullets: z.array(z.string()).default([]), +}); +export type ResumeExperience = z.infer; + +export const ResumeEducationSchema = z.object({ + institution: z.string(), + degree: z.string().optional(), + field: z.string().optional(), + year: z.string().optional(), +}); +export type ResumeEducation = z.infer; + +export const ResumeSchema = z.object({ + name: z.string().optional(), + headline: z.string().optional(), + summary: z.string().optional(), + contact: ResumeContactSchema.default({}), + links: z.array(ResumeLinkSchema).default([]), + experience: z.array(ResumeExperienceSchema).default([]), + education: z.array(ResumeEducationSchema).default([]), + skills: z.array(z.string()).default([]), + // Original document, kept so downstream steps can quote exact source text. + rawText: z.string(), +}); +export type Resume = z.infer; diff --git a/packages/schemas/tsconfig.json b/packages/schemas/tsconfig.json new file mode 100644 index 0000000..d080397 --- /dev/null +++ b/packages/schemas/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/__tests__/**"] +} diff --git a/packages/schemas/vitest.config.ts b/packages/schemas/vitest.config.ts new file mode 100644 index 0000000..4ed8031 --- /dev/null +++ b/packages/schemas/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 889aaad..2c05a4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,22 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/schemas: + dependencies: + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages: '@alloc/quick-lru@5.2.0': @@ -1175,6 +1191,9 @@ packages: engines: {node: '>=8'} hasBin: true + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2053,3 +2072,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + zod@3.25.76: {} From eee572db4614656cb71cd81e5ba81832043157e3 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 18:59:56 +0200 Subject: [PATCH 2/5] feat(intelligence): add rubric v1, archetypes, and validators Scoring brain the prompts reference: rubric v1 (six weighted dimensions with 0-5 anchors), three role archetypes (Software Engineer, Product Manager, Data & ML Engineer), keyword-based detectArchetype, and the ATS and claim validator specs. Closes #63 --- packages/intelligence/README.md | 16 +++++ packages/intelligence/package.json | 37 ++++++++++ .../src/__tests__/intelligence.test.ts | 58 ++++++++++++++++ .../src/archetypes/data-ml-engineer.ts | 52 ++++++++++++++ packages/intelligence/src/archetypes/index.ts | 24 +++++++ .../src/archetypes/product-manager.ts | 53 ++++++++++++++ .../src/archetypes/software-engineer.ts | 54 +++++++++++++++ packages/intelligence/src/detect.ts | 27 ++++++++ packages/intelligence/src/index.ts | 18 +++++ packages/intelligence/src/rubric.ts | 69 +++++++++++++++++++ packages/intelligence/src/validators/ats.ts | 43 ++++++++++++ .../intelligence/src/validators/claims.ts | 20 ++++++ packages/intelligence/tsconfig.json | 10 +++ packages/intelligence/vitest.config.ts | 8 +++ pnpm-lock.yaml | 19 +++++ 15 files changed, 508 insertions(+) create mode 100644 packages/intelligence/README.md create mode 100644 packages/intelligence/package.json create mode 100644 packages/intelligence/src/__tests__/intelligence.test.ts create mode 100644 packages/intelligence/src/archetypes/data-ml-engineer.ts create mode 100644 packages/intelligence/src/archetypes/index.ts create mode 100644 packages/intelligence/src/archetypes/product-manager.ts create mode 100644 packages/intelligence/src/archetypes/software-engineer.ts create mode 100644 packages/intelligence/src/detect.ts create mode 100644 packages/intelligence/src/index.ts create mode 100644 packages/intelligence/src/rubric.ts create mode 100644 packages/intelligence/src/validators/ats.ts create mode 100644 packages/intelligence/src/validators/claims.ts create mode 100644 packages/intelligence/tsconfig.json create mode 100644 packages/intelligence/vitest.config.ts diff --git a/packages/intelligence/README.md b/packages/intelligence/README.md new file mode 100644 index 0000000..0998cd6 --- /dev/null +++ b/packages/intelligence/README.md @@ -0,0 +1,16 @@ +# @cv-builder/intelligence + +The scoring brain the prompts and skill reference: the fixed rubric, the role +archetypes, and the validator specs. + +- **Rubric v1** — six dimensions (Shipped Evidence, Quantified Impact, Tech/Tool + Visibility, ATS Compatibility, Keyword Match, Public Proof) with 0–5 anchors, + tagged `RUBRIC_VERSION`. +- **Archetypes** — role config (keywords, dimension weights, action verbs, + anti-patterns). Ships Software Engineer, Product Manager, Data & ML Engineer. + Add one by dropping a file in `src/archetypes/` and registering it. +- **Validators** — `checkAtsCompatibility()` plus the ATS and claim rule sets + the prompts encode. + +`detectArchetype(resumeText)` picks the best-matching archetype, falling back to +Software Engineer when there's no signal. diff --git a/packages/intelligence/package.json b/packages/intelligence/package.json new file mode 100644 index 0000000..f11ac92 --- /dev/null +++ b/packages/intelligence/package.json @@ -0,0 +1,37 @@ +{ + "name": "@cv-builder/intelligence", + "version": "0.1.0", + "description": "Scoring rubric, role archetypes, and validators for CV evaluation", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cv-builder/schemas": "workspace:*", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "cv", + "resume", + "rubric", + "archetypes", + "ats" + ], + "license": "MIT" +} diff --git a/packages/intelligence/src/__tests__/intelligence.test.ts b/packages/intelligence/src/__tests__/intelligence.test.ts new file mode 100644 index 0000000..16522ac --- /dev/null +++ b/packages/intelligence/src/__tests__/intelligence.test.ts @@ -0,0 +1,58 @@ +import { ArchetypeSchema } from "@cv-builder/schemas"; +import { describe, expect, it } from "vitest"; +import { + ARCHETYPES, + RUBRIC, + checkAtsCompatibility, + detectArchetype, +} from "../index.js"; + +describe("archetypes", () => { + it("ships at least 3, each valid against the schema", () => { + expect(ARCHETYPES.length).toBeGreaterThanOrEqual(3); + for (const archetype of ARCHETYPES) { + expect(ArchetypeSchema.safeParse(archetype).success, archetype.id).toBe(true); + } + }); + + it("has weights summing to 1.0", () => { + for (const { id, evaluationWeights } of ARCHETYPES) { + const sum = Object.values(evaluationWeights).reduce((a, b) => a + b, 0); + expect(sum, id).toBeCloseTo(1, 5); + } + }); + + it("covers every rubric dimension with a weight", () => { + const keys = RUBRIC.dimensions.map((d) => d.key).sort(); + for (const { id, evaluationWeights } of ARCHETYPES) { + expect(Object.keys(evaluationWeights).sort(), id).toEqual(keys); + } + }); +}); + +describe("detectArchetype", () => { + it("detects a software engineer resume", () => { + const text = "Built React and Node services, scaled Postgres, deployed on Kubernetes."; + expect(detectArchetype(text).id).toBe("software-engineer"); + }); + + it("detects a product manager resume", () => { + const text = "Owned the roadmap, ran A/B testing and user research, grew retention."; + expect(detectArchetype(text).id).toBe("product-manager"); + }); + + it("falls back to the default on no signal", () => { + expect(detectArchetype("hello world").id).toBe("software-engineer"); + }); +}); + +describe("checkAtsCompatibility", () => { + it("flags a table layout", () => { + expect(checkAtsCompatibility("| Skills | Years |\n| React | 5 |").compatible).toBe(false); + }); + + it("passes clean single-column text", () => { + const text = "Experience\nBuilt things at Acme.\nContact: jane@example.com"; + expect(checkAtsCompatibility(text).compatible).toBe(true); + }); +}); diff --git a/packages/intelligence/src/archetypes/data-ml-engineer.ts b/packages/intelligence/src/archetypes/data-ml-engineer.ts new file mode 100644 index 0000000..a0d1b5e --- /dev/null +++ b/packages/intelligence/src/archetypes/data-ml-engineer.ts @@ -0,0 +1,52 @@ +import type { Archetype } from "@cv-builder/schemas"; + +export const dataMlEngineer: Archetype = { + id: "data-ml-engineer", + name: "Data & ML Engineer", + description: "Engineers building data pipelines and machine learning systems", + keywords: [ + "python", + "sql", + "spark", + "airflow", + "dbt", + "pandas", + "pytorch", + "tensorflow", + "scikit-learn", + "feature engineering", + "etl", + "data pipeline", + "warehouse", + "snowflake", + "bigquery", + "mlops", + "model serving", + "experiment tracking", + ], + evaluationWeights: { + shippedEvidence: 0.25, + quantifiedImpact: 0.2, + toolingVisibility: 0.25, + atsCompatibility: 0.1, + keywordMatch: 0.1, + publicProof: 0.1, + }, + actionVerbs: [ + "Built", + "Trained", + "Deployed", + "Productionized", + "Optimized", + "Automated", + "Modeled", + "Scaled", + ], + antiPatterns: [ + "familiar with machine learning", + "exposure to data science", + "worked with big data", + "passionate about AI", + ], + version: "1.0.0", +}; diff --git a/packages/intelligence/src/archetypes/index.ts b/packages/intelligence/src/archetypes/index.ts new file mode 100644 index 0000000..de35c50 --- /dev/null +++ b/packages/intelligence/src/archetypes/index.ts @@ -0,0 +1,24 @@ +import type { Archetype } from "@cv-builder/schemas"; +import { dataMlEngineer } from "./data-ml-engineer.js"; +import { productManager } from "./product-manager.js"; +import { softwareEngineer } from "./software-engineer.js"; + +// Register new archetypes here. Software Engineer is the fallback when nothing +// else clearly matches. +export const ARCHETYPES: Archetype[] = [ + softwareEngineer, + productManager, + dataMlEngineer, +]; + +export const DEFAULT_ARCHETYPE = softwareEngineer; + +export function getArchetype(id: string): Archetype | undefined { + return ARCHETYPES.find((a) => a.id === id); +} + +export function listArchetypes(): Archetype[] { + return ARCHETYPES; +} + +export { softwareEngineer, productManager, dataMlEngineer }; diff --git a/packages/intelligence/src/archetypes/product-manager.ts b/packages/intelligence/src/archetypes/product-manager.ts new file mode 100644 index 0000000..5d14bae --- /dev/null +++ b/packages/intelligence/src/archetypes/product-manager.ts @@ -0,0 +1,53 @@ +import type { Archetype } from "@cv-builder/schemas"; + +export const productManager: Archetype = { + id: "product-manager", + name: "Product Manager", + description: "Product managers owning discovery, roadmap, and delivery", + keywords: [ + "roadmap", + "discovery", + "user research", + "a/b testing", + "experiment", + "okrs", + "kpis", + "retention", + "activation", + "funnel", + "churn", + "stakeholder", + "prioritization", + "agile", + "amplitude", + "mixpanel", + "go-to-market", + "pricing", + ], + evaluationWeights: { + shippedEvidence: 0.25, + quantifiedImpact: 0.25, + toolingVisibility: 0.1, + atsCompatibility: 0.15, + keywordMatch: 0.15, + publicProof: 0.1, + }, + actionVerbs: [ + "Launched", + "Owned", + "Drove", + "Grew", + "Defined", + "Prioritized", + "Increased", + "Reduced", + ], + antiPatterns: [ + "passionate about products", + "leveraged synergies", + "aligned stakeholders", + "wore many hats", + "helped with", + ], + version: "1.0.0", +}; diff --git a/packages/intelligence/src/archetypes/software-engineer.ts b/packages/intelligence/src/archetypes/software-engineer.ts new file mode 100644 index 0000000..2dbc0ab --- /dev/null +++ b/packages/intelligence/src/archetypes/software-engineer.ts @@ -0,0 +1,54 @@ +import type { Archetype } from "@cv-builder/schemas"; + +export const softwareEngineer: Archetype = { + id: "software-engineer", + name: "Software Engineer", + description: "Frontend, backend, and full-stack engineers building software", + keywords: [ + "typescript", + "javascript", + "python", + "go", + "java", + "react", + "node", + "postgres", + "redis", + "kafka", + "docker", + "kubernetes", + "aws", + "ci/cd", + "microservices", + "rest", + "graphql", + "testing", + "system design", + ], + evaluationWeights: { + shippedEvidence: 0.3, + quantifiedImpact: 0.2, + toolingVisibility: 0.2, + atsCompatibility: 0.1, + keywordMatch: 0.1, + publicProof: 0.1, + }, + actionVerbs: [ + "Built", + "Shipped", + "Designed", + "Scaled", + "Optimized", + "Migrated", + "Automated", + "Refactored", + ], + antiPatterns: [ + "familiar with", + "exposure to", + "team player", + "fast learner", + "responsible for various tasks", + ], + version: "1.0.0", +}; diff --git a/packages/intelligence/src/detect.ts b/packages/intelligence/src/detect.ts new file mode 100644 index 0000000..1c19dfa --- /dev/null +++ b/packages/intelligence/src/detect.ts @@ -0,0 +1,27 @@ +import type { Archetype } from "@cv-builder/schemas"; +import { ARCHETYPES, DEFAULT_ARCHETYPE } from "./archetypes/index.js"; + +function countMatches(text: string, keywords: string[]): number { + return keywords.reduce((n, kw) => (text.includes(kw.toLowerCase()) ? n + 1 : n), 0); +} + +// Picks the archetype whose keywords appear most in the resume. Falls back to +// the default when there's no signal, so detection never returns nothing. +export function detectArchetype( + resumeText: string, + archetypes: Archetype[] = ARCHETYPES, +): Archetype { + const text = resumeText.toLowerCase(); + let best = DEFAULT_ARCHETYPE; + let bestScore = 0; + + for (const archetype of archetypes) { + const score = countMatches(text, archetype.keywords); + if (score > bestScore) { + best = archetype; + bestScore = score; + } + } + + return best; +} diff --git a/packages/intelligence/src/index.ts b/packages/intelligence/src/index.ts new file mode 100644 index 0000000..528f328 --- /dev/null +++ b/packages/intelligence/src/index.ts @@ -0,0 +1,18 @@ +// Rubric v1, role archetypes, and the validator specs the prompts encode. + +export { RUBRIC, RUBRIC_VERSION, RUBRIC_DIMENSIONS, type RubricDimension } from "./rubric.js"; + +export { + ARCHETYPES, + DEFAULT_ARCHETYPE, + getArchetype, + listArchetypes, + softwareEngineer, + productManager, + dataMlEngineer, +} from "./archetypes/index.js"; + +export { detectArchetype } from "./detect.js"; + +export { ATS_RULES, checkAtsCompatibility, type AtsCheck } from "./validators/ats.js"; +export { CLAIM_RULES } from "./validators/claims.js"; diff --git a/packages/intelligence/src/rubric.ts b/packages/intelligence/src/rubric.ts new file mode 100644 index 0000000..3494ef4 --- /dev/null +++ b/packages/intelligence/src/rubric.ts @@ -0,0 +1,69 @@ +export const RUBRIC_VERSION = "1.0.0"; + +// `key` matches a field in EvaluationWeights so a dimension and its archetype +// weight always line up. +export interface RubricDimension { + key: + | "shippedEvidence" + | "quantifiedImpact" + | "toolingVisibility" + | "atsCompatibility" + | "keywordMatch" + | "publicProof"; + name: string; + description: string; + // What a 0 vs a 5 looks like, used by the score prompt as anchors. + low: string; + high: string; +} + +export const RUBRIC_DIMENSIONS: RubricDimension[] = [ + { + key: "shippedEvidence", + name: "Shipped Evidence", + description: "Real work that reached production, with named tools and outcomes.", + low: "Responsibilities only; nothing shows it shipped.", + high: "Every role names what was built, for whom, and the result.", + }, + { + key: "quantifiedImpact", + name: "Quantified Impact", + description: "Numbers that frame scope, speed, adoption, or savings.", + low: "No metrics anywhere.", + high: "Most bullets carry a concrete, credible number.", + }, + { + key: "toolingVisibility", + name: "Tech/Tool Visibility", + description: "Named technologies that match the target role.", + low: "Vague or no tech named.", + high: "Relevant stack is explicit and used in context, not just listed.", + }, + { + key: "atsCompatibility", + name: "ATS Compatibility", + description: "Single column, standard headings, parseable formatting.", + low: "Tables, columns, or graphics that parsers mangle.", + high: "Clean single-column text an ATS reads without loss.", + }, + { + key: "keywordMatch", + name: "Keyword Match", + description: + "Overlap with the JD when provided, else the role-family keyword set.", + low: "Misses the terms the role screens for.", + high: "Naturally covers the role's expected keywords.", + }, + { + key: "publicProof", + name: "Public Proof Surface", + description: "GitHub, portfolio, blog, or other verifiable presence.", + low: "No links or external proof.", + high: "Working links that back up the claims.", + }, +]; + +export const RUBRIC = { + version: RUBRIC_VERSION, + dimensions: RUBRIC_DIMENSIONS, +} as const; diff --git a/packages/intelligence/src/validators/ats.ts b/packages/intelligence/src/validators/ats.ts new file mode 100644 index 0000000..86de78a --- /dev/null +++ b/packages/intelligence/src/validators/ats.ts @@ -0,0 +1,43 @@ +// Rules the score prompt and the deterministic check below both follow. +export const ATS_RULES = [ + { id: "single-column", label: "Single-column layout, no side-by-side text" }, + { id: "standard-headings", label: "Standard section headings" }, + { id: "no-tables", label: "No tables or text laid out in columns" }, + { id: "no-graphics", label: "No images, icons, or text inside graphics" }, + { id: "contact-parseable", label: "Email and basic contact info in plain text" }, +] as const; + +const STANDARD_HEADINGS = [ + "experience", + "work experience", + "employment", + "education", + "skills", + "projects", + "summary", + "certifications", +]; + +export interface AtsCheck { + compatible: boolean; + findings: string[]; +} + +// Catches the obvious, deterministic ATS problems from raw text. Subtler layout +// issues are left to the score prompt. +export function checkAtsCompatibility(resumeText: string): AtsCheck { + const findings: string[] = []; + const text = resumeText.toLowerCase(); + + if (/\|.*\|/.test(resumeText) || /\t.*\t/.test(resumeText)) { + findings.push("Looks like a table or columns — flatten to single-column text."); + } + if (!/[\w.+-]+@[\w-]+\.[\w.-]+/.test(resumeText)) { + findings.push("No plain-text email found."); + } + if (!STANDARD_HEADINGS.some((h) => text.includes(h))) { + findings.push("No standard section headings detected."); + } + + return { compatible: findings.length === 0, findings }; +} diff --git a/packages/intelligence/src/validators/claims.ts b/packages/intelligence/src/validators/claims.ts new file mode 100644 index 0000000..aadb14b --- /dev/null +++ b/packages/intelligence/src/validators/claims.ts @@ -0,0 +1,20 @@ +// What the validate-claims prompt treats as an unsupported claim. The check is +// LLM-driven; this is the shared spec it encodes. +export const CLAIM_RULES = [ + { + id: "metric-without-context", + label: "A number with no scope, baseline, or how it was achieved", + }, + { + id: "tool-never-used", + label: "A tool or technology listed but never shown in any bullet", + }, + { + id: "seniority-without-scope", + label: "A leadership or scale claim with no team size, budget, or reach", + }, + { + id: "vague-superlative", + label: "Superlatives ('world-class', 'expert') with nothing backing them", + }, +] as const; diff --git a/packages/intelligence/tsconfig.json b/packages/intelligence/tsconfig.json new file mode 100644 index 0000000..d080397 --- /dev/null +++ b/packages/intelligence/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/__tests__/**"] +} diff --git a/packages/intelligence/vitest.config.ts b/packages/intelligence/vitest.config.ts new file mode 100644 index 0000000..4ed8031 --- /dev/null +++ b/packages/intelligence/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c05a4e..625b637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,25 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/intelligence: + dependencies: + '@cv-builder/schemas': + specifier: workspace:* + version: link:../schemas + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/schemas: dependencies: zod: From 6748d7ee3a4fc6f7e7783d99fcb5eaa4f53231a7 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 13:33:00 +0200 Subject: [PATCH 3/5] refactor(schemas): drop maxScore, rubric scale is fixed 0-5 --- packages/schemas/src/__tests__/schemas.test.ts | 1 - packages/schemas/src/evaluation.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/schemas/src/__tests__/schemas.test.ts b/packages/schemas/src/__tests__/schemas.test.ts index 848f937..08e1265 100644 --- a/packages/schemas/src/__tests__/schemas.test.ts +++ b/packages/schemas/src/__tests__/schemas.test.ts @@ -16,7 +16,6 @@ const validEvalResult = { name: "Shipped Evidence", weight: 0.3, score: 4, - maxScore: 5, feedback: "Strong production work with named outcomes.", }, ], diff --git a/packages/schemas/src/evaluation.ts b/packages/schemas/src/evaluation.ts index 36b01ed..4038d1c 100644 --- a/packages/schemas/src/evaluation.ts +++ b/packages/schemas/src/evaluation.ts @@ -4,7 +4,6 @@ export const EvaluationDimensionSchema = z.object({ name: z.string(), weight: z.number().min(0).max(1), score: z.number().int().min(0).max(5), - maxScore: z.number().int().positive().default(5), feedback: z.string(), }); export type EvaluationDimension = z.infer; From ab36770119f16928c07a861db44b0ee21478f4e0 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 13:42:47 +0200 Subject: [PATCH 4/5] chore(schemas): apply biome formatting --- .../schemas/src/__tests__/schemas.test.ts | 6 +-- packages/schemas/src/evaluation.ts | 9 +--- packages/schemas/src/index.ts | 44 +++++++++---------- 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/packages/schemas/src/__tests__/schemas.test.ts b/packages/schemas/src/__tests__/schemas.test.ts index 08e1265..7faa566 100644 --- a/packages/schemas/src/__tests__/schemas.test.ts +++ b/packages/schemas/src/__tests__/schemas.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - ArchetypeSchema, - EvalResultSchema, - ResumeSchema, -} from "../index.js"; +import { ArchetypeSchema, EvalResultSchema, ResumeSchema } from "../index.js"; const validEvalResult = { rubricVersion: "1.0.0", diff --git a/packages/schemas/src/evaluation.ts b/packages/schemas/src/evaluation.ts index 4038d1c..4ab78b3 100644 --- a/packages/schemas/src/evaluation.ts +++ b/packages/schemas/src/evaluation.ts @@ -19,14 +19,7 @@ export type Issue = z.infer; export const ClaimSchema = z.object({ text: z.string(), - category: z.enum([ - "tool", - "technology", - "metric", - "experience", - "education", - "other", - ]), + category: z.enum(["tool", "technology", "metric", "experience", "education", "other"]), supported: z.boolean(), reason: z.string(), }); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 87b220e..ffff15a 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -2,36 +2,34 @@ // Phase 1 ships evaluation types only — tailoring/rewrite is Phase 2. export { - EvaluationWeightsSchema, + type Archetype, ArchetypeSchema, type EvaluationWeights, - type Archetype, + EvaluationWeightsSchema, } from "./archetype.js"; +export { + type Claim, + ClaimSchema, + type EvalResult, + EvalResultSchema, + type EvaluationDimension, + EvaluationDimensionSchema, + type Issue, + IssueSchema, +} from "./evaluation.js"; +export { type JobDescription, JobDescriptionSchema } from "./job-description.js"; export { - ResumeSourceSchema, - ResumeLinkSchema, + type Resume, + type ResumeContact, ResumeContactSchema, - ResumeExperienceSchema, + type ResumeEducation, ResumeEducationSchema, + type ResumeExperience, + ResumeExperienceSchema, + type ResumeLink, + ResumeLinkSchema, ResumeSchema, type ResumeSource, - type ResumeLink, - type ResumeContact, - type ResumeExperience, - type ResumeEducation, - type Resume, + ResumeSourceSchema, } from "./resume.js"; - -export { JobDescriptionSchema, type JobDescription } from "./job-description.js"; - -export { - EvaluationDimensionSchema, - IssueSchema, - ClaimSchema, - EvalResultSchema, - type EvaluationDimension, - type Issue, - type Claim, - type EvalResult, -} from "./evaluation.js"; From d438cc7bf1e1059af670d859166cec31c6aad7ed Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 14:09:37 +0200 Subject: [PATCH 5/5] fix(intelligence): match keywords on word boundaries, drop unused zod dep --- packages/intelligence/package.json | 3 +-- .../src/__tests__/intelligence.test.ts | 20 +++++++++++-------- packages/intelligence/src/archetypes/index.ts | 8 ++------ packages/intelligence/src/detect.ts | 10 ++++++++-- packages/intelligence/src/index.ts | 15 ++++++++------ packages/intelligence/src/rubric.ts | 3 +-- pnpm-lock.yaml | 3 --- 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/intelligence/package.json b/packages/intelligence/package.json index f11ac92..b59f64c 100644 --- a/packages/intelligence/package.json +++ b/packages/intelligence/package.json @@ -18,8 +18,7 @@ "test": "vitest run" }, "dependencies": { - "@cv-builder/schemas": "workspace:*", - "zod": "^3.24.0" + "@cv-builder/schemas": "workspace:*" }, "devDependencies": { "@types/node": "^25.6.2", diff --git a/packages/intelligence/src/__tests__/intelligence.test.ts b/packages/intelligence/src/__tests__/intelligence.test.ts index 16522ac..849b8fa 100644 --- a/packages/intelligence/src/__tests__/intelligence.test.ts +++ b/packages/intelligence/src/__tests__/intelligence.test.ts @@ -1,11 +1,6 @@ import { ArchetypeSchema } from "@cv-builder/schemas"; import { describe, expect, it } from "vitest"; -import { - ARCHETYPES, - RUBRIC, - checkAtsCompatibility, - detectArchetype, -} from "../index.js"; +import { ARCHETYPES, checkAtsCompatibility, detectArchetype, RUBRIC } from "../index.js"; describe("archetypes", () => { it("ships at least 3, each valid against the schema", () => { @@ -32,7 +27,8 @@ describe("archetypes", () => { describe("detectArchetype", () => { it("detects a software engineer resume", () => { - const text = "Built React and Node services, scaled Postgres, deployed on Kubernetes."; + const text = + "Built React and Node services, scaled Postgres, deployed on Kubernetes."; expect(detectArchetype(text).id).toBe("software-engineer"); }); @@ -44,11 +40,19 @@ describe("detectArchetype", () => { it("falls back to the default on no signal", () => { expect(detectArchetype("hello world").id).toBe("software-engineer"); }); + + it("matches whole words only, not substrings", () => { + const text = + "Drove go-to-market with MongoDB-backed analytics, owned the roadmap and funnel."; + expect(detectArchetype(text).id).toBe("product-manager"); + }); }); describe("checkAtsCompatibility", () => { it("flags a table layout", () => { - expect(checkAtsCompatibility("| Skills | Years |\n| React | 5 |").compatible).toBe(false); + expect(checkAtsCompatibility("| Skills | Years |\n| React | 5 |").compatible).toBe( + false + ); }); it("passes clean single-column text", () => { diff --git a/packages/intelligence/src/archetypes/index.ts b/packages/intelligence/src/archetypes/index.ts index de35c50..5cfcd87 100644 --- a/packages/intelligence/src/archetypes/index.ts +++ b/packages/intelligence/src/archetypes/index.ts @@ -5,11 +5,7 @@ import { softwareEngineer } from "./software-engineer.js"; // Register new archetypes here. Software Engineer is the fallback when nothing // else clearly matches. -export const ARCHETYPES: Archetype[] = [ - softwareEngineer, - productManager, - dataMlEngineer, -]; +export const ARCHETYPES: Archetype[] = [softwareEngineer, productManager, dataMlEngineer]; export const DEFAULT_ARCHETYPE = softwareEngineer; @@ -21,4 +17,4 @@ export function listArchetypes(): Archetype[] { return ARCHETYPES; } -export { softwareEngineer, productManager, dataMlEngineer }; +export { dataMlEngineer, productManager, softwareEngineer }; diff --git a/packages/intelligence/src/detect.ts b/packages/intelligence/src/detect.ts index 1c19dfa..5e90407 100644 --- a/packages/intelligence/src/detect.ts +++ b/packages/intelligence/src/detect.ts @@ -1,15 +1,21 @@ import type { Archetype } from "@cv-builder/schemas"; import { ARCHETYPES, DEFAULT_ARCHETYPE } from "./archetypes/index.js"; +function hasKeyword(text: string, keyword: string): boolean { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // hyphen counts as a word char so "go" doesn't match "go-to-market" + return new RegExp(`(? (text.includes(kw.toLowerCase()) ? n + 1 : n), 0); + return keywords.reduce((n, kw) => (hasKeyword(text, kw.toLowerCase()) ? n + 1 : n), 0); } // Picks the archetype whose keywords appear most in the resume. Falls back to // the default when there's no signal, so detection never returns nothing. export function detectArchetype( resumeText: string, - archetypes: Archetype[] = ARCHETYPES, + archetypes: Archetype[] = ARCHETYPES ): Archetype { const text = resumeText.toLowerCase(); let best = DEFAULT_ARCHETYPE; diff --git a/packages/intelligence/src/index.ts b/packages/intelligence/src/index.ts index 528f328..fb085be 100644 --- a/packages/intelligence/src/index.ts +++ b/packages/intelligence/src/index.ts @@ -1,18 +1,21 @@ // Rubric v1, role archetypes, and the validator specs the prompts encode. -export { RUBRIC, RUBRIC_VERSION, RUBRIC_DIMENSIONS, type RubricDimension } from "./rubric.js"; - export { ARCHETYPES, DEFAULT_ARCHETYPE, + dataMlEngineer, getArchetype, listArchetypes, - softwareEngineer, productManager, - dataMlEngineer, + softwareEngineer, } from "./archetypes/index.js"; - export { detectArchetype } from "./detect.js"; +export { + RUBRIC, + RUBRIC_DIMENSIONS, + RUBRIC_VERSION, + type RubricDimension, +} from "./rubric.js"; -export { ATS_RULES, checkAtsCompatibility, type AtsCheck } from "./validators/ats.js"; +export { ATS_RULES, type AtsCheck, checkAtsCompatibility } from "./validators/ats.js"; export { CLAIM_RULES } from "./validators/claims.js"; diff --git a/packages/intelligence/src/rubric.ts b/packages/intelligence/src/rubric.ts index 3494ef4..8498065 100644 --- a/packages/intelligence/src/rubric.ts +++ b/packages/intelligence/src/rubric.ts @@ -49,8 +49,7 @@ export const RUBRIC_DIMENSIONS: RubricDimension[] = [ { key: "keywordMatch", name: "Keyword Match", - description: - "Overlap with the JD when provided, else the role-family keyword set.", + description: "Overlap with the JD when provided, else the role-family keyword set.", low: "Misses the terms the role screens for.", high: "Naturally covers the role's expected keywords.", }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7084c52..003467f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,9 +91,6 @@ importers: '@cv-builder/schemas': specifier: workspace:* version: link:../schemas - zod: - specifier: ^3.24.0 - version: 3.25.76 devDependencies: '@types/node': specifier: ^25.6.2