Skip to content

Commit 170b92c

Browse files
author
Cleanup Bot
committed
Merge PR #68: add Zod schema contract package
2 parents 7c0fba6 + ab36770 commit 170b92c

11 files changed

Lines changed: 355 additions & 0 deletions

File tree

packages/schemas/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# @cv-builder/schemas
2+
3+
[Zod](https://zod.dev) schemas shared across CV Builder surfaces. Schemas are
4+
the source of truth; TypeScript types are inferred from them.
5+
6+
Phase 1 requires that no LLM response is used without passing Zod validation,
7+
and that every `EvalResult` carries a `rubricVersion` and `archetypeVersion`.
8+
9+
```ts
10+
import { EvalResultSchema, type EvalResult } from "@cv-builder/schemas";
11+
12+
const result: EvalResult = EvalResultSchema.parse(rawModelOutput);
13+
```
14+
15+
Exports: `Resume` / `ResumeSource`, `JobDescription`, `Archetype`,
16+
`EvaluationWeights`, `EvaluationDimension`, `Issue`, `Claim`, `EvalResult`
17+
(each with its `*Schema`). Evaluation types only — tailoring is Phase 2.

packages/schemas/package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@cv-builder/schemas",
3+
"version": "0.1.0",
4+
"description": "Zod schemas and inferred types — the validated contract shared across CV Builder",
5+
"type": "module",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js"
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsc",
16+
"dev": "tsc --watch",
17+
"lint": "tsc --noEmit",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"zod": "^3.25.76"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.6.2",
25+
"typescript": "^5.7.0",
26+
"vitest": "^3.0.0"
27+
},
28+
"keywords": [
29+
"cv",
30+
"resume",
31+
"zod",
32+
"schema",
33+
"types"
34+
],
35+
"license": "MIT"
36+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from "vitest";
2+
import { ArchetypeSchema, EvalResultSchema, ResumeSchema } from "../index.js";
3+
4+
const validEvalResult = {
5+
rubricVersion: "1.0.0",
6+
archetypeVersion: "1.0.0",
7+
archetypeId: "software-engineer",
8+
archetypeName: "Software Engineer",
9+
score: 3.4,
10+
dimensions: [
11+
{
12+
name: "Shipped Evidence",
13+
weight: 0.3,
14+
score: 4,
15+
feedback: "Strong production work with named outcomes.",
16+
},
17+
],
18+
strengths: ["Clear quantified impact"],
19+
issues: [
20+
{
21+
element: "Summary",
22+
quote: "Passionate team player",
23+
why: "Generic phrasing with no evidence.",
24+
fix: "Replace with a concrete, measurable achievement.",
25+
severity: "major",
26+
},
27+
],
28+
claims: [
29+
{
30+
text: "Scaled system to 1M users",
31+
category: "metric",
32+
supported: false,
33+
reason: "No corroborating detail elsewhere in the resume.",
34+
},
35+
],
36+
atsCompatible: true,
37+
};
38+
39+
describe("EvalResultSchema", () => {
40+
it("parses a well-formed result", () => {
41+
expect(() => EvalResultSchema.parse(validEvalResult)).not.toThrow();
42+
});
43+
44+
it("rejects a malformed result", () => {
45+
const bad = { ...validEvalResult, score: "high" };
46+
expect(EvalResultSchema.safeParse(bad).success).toBe(false);
47+
});
48+
49+
it("requires rubricVersion and archetypeVersion", () => {
50+
for (const field of ["rubricVersion", "archetypeVersion"]) {
51+
const partial = { ...validEvalResult };
52+
delete (partial as Record<string, unknown>)[field];
53+
const result = EvalResultSchema.safeParse(partial);
54+
expect(result.success, `${field} should be required`).toBe(false);
55+
}
56+
});
57+
58+
it("rejects an out-of-range dimension score", () => {
59+
const bad = {
60+
...validEvalResult,
61+
dimensions: [{ ...validEvalResult.dimensions[0], score: 7 }],
62+
};
63+
expect(EvalResultSchema.safeParse(bad).success).toBe(false);
64+
});
65+
});
66+
67+
describe("ResumeSchema", () => {
68+
it("applies array/object defaults from rawText alone", () => {
69+
const resume = ResumeSchema.parse({ rawText: "Jane Doe — Engineer" });
70+
expect(resume.links).toEqual([]);
71+
expect(resume.skills).toEqual([]);
72+
expect(resume.contact).toEqual({});
73+
});
74+
});
75+
76+
describe("ArchetypeSchema", () => {
77+
it("requires at least one keyword", () => {
78+
const archetype = {
79+
id: "software-engineer",
80+
name: "Software Engineer",
81+
description: "Builds and ships software",
82+
keywords: [],
83+
evaluationWeights: {
84+
shippedEvidence: 0.3,
85+
quantifiedImpact: 0.2,
86+
toolingVisibility: 0.2,
87+
atsCompatibility: 0.1,
88+
keywordMatch: 0.1,
89+
publicProof: 0.1,
90+
},
91+
actionVerbs: ["Built"],
92+
antiPatterns: ["familiar with"],
93+
version: "1.0.0",
94+
};
95+
expect(ArchetypeSchema.safeParse(archetype).success).toBe(false);
96+
});
97+
});

packages/schemas/src/archetype.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { z } from "zod";
2+
3+
export const EvaluationWeightsSchema = z.object({
4+
shippedEvidence: z.number().min(0).max(1),
5+
quantifiedImpact: z.number().min(0).max(1),
6+
toolingVisibility: z.number().min(0).max(1),
7+
atsCompatibility: z.number().min(0).max(1),
8+
keywordMatch: z.number().min(0).max(1),
9+
publicProof: z.number().min(0).max(1),
10+
});
11+
export type EvaluationWeights = z.infer<typeof EvaluationWeightsSchema>;
12+
13+
// Weights are expected to sum to ~1.0, but that's enforced in the intelligence
14+
// layer so a half-edited archetype still parses here.
15+
export const ArchetypeSchema = z.object({
16+
id: z.string().min(1),
17+
name: z.string().min(1),
18+
description: z.string(),
19+
keywords: z.array(z.string()).min(1),
20+
evaluationWeights: EvaluationWeightsSchema,
21+
actionVerbs: z.array(z.string()),
22+
antiPatterns: z.array(z.string()),
23+
version: z.string(),
24+
});
25+
export type Archetype = z.infer<typeof ArchetypeSchema>;

packages/schemas/src/evaluation.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { z } from "zod";
2+
3+
export const EvaluationDimensionSchema = z.object({
4+
name: z.string(),
5+
weight: z.number().min(0).max(1),
6+
score: z.number().int().min(0).max(5),
7+
feedback: z.string(),
8+
});
9+
export type EvaluationDimension = z.infer<typeof EvaluationDimensionSchema>;
10+
11+
export const IssueSchema = z.object({
12+
element: z.string(),
13+
quote: z.string().optional(),
14+
why: z.string(),
15+
fix: z.string(),
16+
severity: z.enum(["critical", "major", "minor"]),
17+
});
18+
export type Issue = z.infer<typeof IssueSchema>;
19+
20+
export const ClaimSchema = z.object({
21+
text: z.string(),
22+
category: z.enum(["tool", "technology", "metric", "experience", "education", "other"]),
23+
supported: z.boolean(),
24+
reason: z.string(),
25+
});
26+
export type Claim = z.infer<typeof ClaimSchema>;
27+
28+
// Versions are required so old results stay reproducible when the rubric or an
29+
// archetype changes later.
30+
export const EvalResultSchema = z.object({
31+
rubricVersion: z.string(),
32+
archetypeVersion: z.string(),
33+
archetypeId: z.string(),
34+
archetypeName: z.string(),
35+
score: z.number().min(0).max(5),
36+
dimensions: z.array(EvaluationDimensionSchema),
37+
strengths: z.array(z.string()).default([]),
38+
issues: z.array(IssueSchema).default([]),
39+
claims: z.array(ClaimSchema).default([]),
40+
atsCompatible: z.boolean(),
41+
locale: z.string().optional(),
42+
});
43+
export type EvalResult = z.infer<typeof EvalResultSchema>;

packages/schemas/src/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Zod schemas are the source of truth; types are inferred from them.
2+
// Phase 1 ships evaluation types only — tailoring/rewrite is Phase 2.
3+
4+
export {
5+
type Archetype,
6+
ArchetypeSchema,
7+
type EvaluationWeights,
8+
EvaluationWeightsSchema,
9+
} from "./archetype.js";
10+
export {
11+
type Claim,
12+
ClaimSchema,
13+
type EvalResult,
14+
EvalResultSchema,
15+
type EvaluationDimension,
16+
EvaluationDimensionSchema,
17+
type Issue,
18+
IssueSchema,
19+
} from "./evaluation.js";
20+
21+
export { type JobDescription, JobDescriptionSchema } from "./job-description.js";
22+
export {
23+
type Resume,
24+
type ResumeContact,
25+
ResumeContactSchema,
26+
type ResumeEducation,
27+
ResumeEducationSchema,
28+
type ResumeExperience,
29+
ResumeExperienceSchema,
30+
type ResumeLink,
31+
ResumeLinkSchema,
32+
ResumeSchema,
33+
type ResumeSource,
34+
ResumeSourceSchema,
35+
} from "./resume.js";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { z } from "zod";
2+
3+
// Phase 1 uses the JD only as keyword-match context; fit-scoring is Phase 2.
4+
export const JobDescriptionSchema = z.object({
5+
content: z.string(),
6+
url: z.string().optional(),
7+
company: z.string().optional(),
8+
title: z.string().optional(),
9+
keywords: z.array(z.string()).default([]),
10+
});
11+
export type JobDescription = z.infer<typeof JobDescriptionSchema>;

packages/schemas/src/resume.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { z } from "zod";
2+
3+
// Raw input as it arrives from a surface, before extraction.
4+
export const ResumeSourceSchema = z.object({
5+
content: z.string(),
6+
format: z.enum(["pdf", "markdown", "plaintext", "html"]),
7+
});
8+
export type ResumeSource = z.infer<typeof ResumeSourceSchema>;
9+
10+
export const ResumeLinkSchema = z.object({
11+
type: z.enum(["github", "linkedin", "portfolio", "blog", "website", "other"]),
12+
url: z.string(),
13+
});
14+
export type ResumeLink = z.infer<typeof ResumeLinkSchema>;
15+
16+
export const ResumeContactSchema = z.object({
17+
email: z.string().optional(),
18+
phone: z.string().optional(),
19+
location: z.string().optional(),
20+
});
21+
export type ResumeContact = z.infer<typeof ResumeContactSchema>;
22+
23+
export const ResumeExperienceSchema = z.object({
24+
company: z.string(),
25+
role: z.string(),
26+
startDate: z.string().optional(),
27+
endDate: z.string().optional(),
28+
bullets: z.array(z.string()).default([]),
29+
});
30+
export type ResumeExperience = z.infer<typeof ResumeExperienceSchema>;
31+
32+
export const ResumeEducationSchema = z.object({
33+
institution: z.string(),
34+
degree: z.string().optional(),
35+
field: z.string().optional(),
36+
year: z.string().optional(),
37+
});
38+
export type ResumeEducation = z.infer<typeof ResumeEducationSchema>;
39+
40+
export const ResumeSchema = z.object({
41+
name: z.string().optional(),
42+
headline: z.string().optional(),
43+
summary: z.string().optional(),
44+
contact: ResumeContactSchema.default({}),
45+
links: z.array(ResumeLinkSchema).default([]),
46+
experience: z.array(ResumeExperienceSchema).default([]),
47+
education: z.array(ResumeEducationSchema).default([]),
48+
skills: z.array(z.string()).default([]),
49+
// Original document, kept so downstream steps can quote exact source text.
50+
rawText: z.string(),
51+
});
52+
export type Resume = z.infer<typeof ResumeSchema>;

packages/schemas/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src",
6+
"types": ["node"]
7+
},
8+
"include": ["src/**/*"],
9+
"exclude": ["src/__tests__/**"]
10+
}

packages/schemas/vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
globals: false,
6+
environment: "node",
7+
},
8+
});

0 commit comments

Comments
 (0)