@thejob/schema 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +7 -0
- package/dist/index.cjs +1350 -130
- package/dist/index.d.cts +430 -1
- package/dist/index.d.ts +430 -1
- package/dist/index.js +1328 -125
- package/package.json +1 -1
- package/src/index.ts +8 -0
- package/src/job/job-taxonomy.constant.ts +1104 -0
- package/src/job/job-taxonomy.util.ts +111 -0
- package/src/job/job.schema.ts +30 -0
- package/src/post/post.constant.ts +7 -0
- package/src/post/post.schema.ts +68 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JOB_TAXONOMY,
|
|
3
|
+
type JobCategory,
|
|
4
|
+
type JobIndustry,
|
|
5
|
+
type JobSubCategory,
|
|
6
|
+
} from "./job-taxonomy.constant.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Taxonomy lookups + industry derivation.
|
|
10
|
+
*
|
|
11
|
+
* Hand-written (unlike job-taxonomy.constant.ts, which is generated) — the generator
|
|
12
|
+
* will not overwrite this file.
|
|
13
|
+
*
|
|
14
|
+
* WHY DERIVE INDUSTRY INSTEAD OF ASKING FOR IT
|
|
15
|
+
* `industry` is redundant: every subCategory name is globally unique and every industry
|
|
16
|
+
* belongs to exactly one category, so a subCategory already determines both. Asking an
|
|
17
|
+
* LLM to pick the industry *as well* asks it to re-derive what it just implied — and
|
|
18
|
+
* measured across ~57k enriched jobs it derived differently 15.9% of the time (e.g.
|
|
19
|
+
* "Data Science" filed under "Software & Web Development" rather than "Data, AI &
|
|
20
|
+
* Analytics"). 89.5% of those were a sibling industry under the SAME category: the right
|
|
21
|
+
* neighbourhood, the wrong box. Deriving in code removes that failure mode entirely
|
|
22
|
+
* rather than validating against it — a mismatch must never cost us the posting.
|
|
23
|
+
*
|
|
24
|
+
* WHY CATEGORY IS STILL ASKED FOR, NOT DERIVED
|
|
25
|
+
* The reverse (derive category from the subCategory too) measured 98.5% agreement, but
|
|
26
|
+
* the disagreements showed the MODEL was usually right and the tree wrong: a "Financial
|
|
27
|
+
* Analyst" tagged `business_intelligence` derives to Technology, while the model said
|
|
28
|
+
* Finance — and it is a finance job that uses BI tools. The category captures what the
|
|
29
|
+
* job IS; a subCategory's position in the tree does not. So the model keeps choosing it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** subCategory -> the industry that owns it. */
|
|
33
|
+
const INDUSTRY_OF_SUB = new Map<string, JobIndustry>(
|
|
34
|
+
JOB_TAXONOMY.flatMap((c) =>
|
|
35
|
+
c.industries.flatMap((i) => i.subCategories.map((s) => [s, i.industry] as const)),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** subCategory -> the category that owns it (via its industry). */
|
|
40
|
+
const CATEGORY_OF_SUB = new Map<string, JobCategory>(
|
|
41
|
+
JOB_TAXONOMY.flatMap((c) =>
|
|
42
|
+
c.industries.flatMap((i) => i.subCategories.map((s) => [s, c.category] as const)),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** industry -> the category that owns it. */
|
|
47
|
+
const CATEGORY_OF_INDUSTRY = new Map<string, JobCategory>(
|
|
48
|
+
JOB_TAXONOMY.flatMap((c) => c.industries.map((i) => [i.industry, c.category] as const)),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** The industry a subCategory belongs to, or undefined if it is not a known subCategory. */
|
|
52
|
+
export function industryOfSubCategory(sub: string): JobIndustry | undefined {
|
|
53
|
+
return INDUSTRY_OF_SUB.get(sub);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The category a subCategory belongs to, or undefined if unknown. */
|
|
57
|
+
export function categoryOfSubCategory(sub: string): JobCategory | undefined {
|
|
58
|
+
return CATEGORY_OF_SUB.get(sub);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The category an industry belongs to, or undefined if unknown. */
|
|
62
|
+
export function categoryOfIndustry(industry: string): JobCategory | undefined {
|
|
63
|
+
return CATEGORY_OF_INDUSTRY.get(industry);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The industries under a category (empty if the category is unknown). */
|
|
67
|
+
export function industriesOfCategory(category: string): readonly JobIndustry[] {
|
|
68
|
+
return JOB_TAXONOMY.find((c) => c.category === category)?.industries.map((i) => i.industry) ?? [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The subCategories under an industry (empty if the industry is unknown). */
|
|
72
|
+
export function subCategoriesOfIndustry(industry: string): readonly JobSubCategory[] {
|
|
73
|
+
for (const c of JOB_TAXONOMY) {
|
|
74
|
+
for (const i of c.industries) if (i.industry === industry) return i.subCategories;
|
|
75
|
+
}
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive a job's industry from its category + subCategories. This is the function that
|
|
81
|
+
* replaces asking the model for `industry`.
|
|
82
|
+
*
|
|
83
|
+
* Rule: the first subCategory that sits INSIDE the given category wins. Preferring the
|
|
84
|
+
* category keeps the model's judgement about what the job is (see the file header) while
|
|
85
|
+
* still deriving a structurally coherent industry; measured on ~57k jobs, 98.7% of those
|
|
86
|
+
* with a real category and real subCategories have at least one subCategory inside it.
|
|
87
|
+
*
|
|
88
|
+
* Falls back, in order:
|
|
89
|
+
* 1. no category given -> the first known subCategory's own industry;
|
|
90
|
+
* 2. no subCategory sits in the category -> undefined (caller keeps whatever it had,
|
|
91
|
+
* or stores null; we never drop a posting over this).
|
|
92
|
+
*
|
|
93
|
+
* Order matters: `subCategories` is the model's own ranking, most relevant first.
|
|
94
|
+
*/
|
|
95
|
+
export function deriveIndustry(
|
|
96
|
+
category: string | null | undefined,
|
|
97
|
+
subCategories: readonly string[] | null | undefined,
|
|
98
|
+
): JobIndustry | undefined {
|
|
99
|
+
const subs = (subCategories ?? []).filter((s) => INDUSTRY_OF_SUB.has(s));
|
|
100
|
+
if (subs.length === 0) return undefined;
|
|
101
|
+
|
|
102
|
+
if (category) {
|
|
103
|
+
const inCategory = subs.find((s) => CATEGORY_OF_SUB.get(s) === category);
|
|
104
|
+
if (inCategory) return INDUSTRY_OF_SUB.get(inCategory);
|
|
105
|
+
// The model's subCategories all sit outside its chosen category. Rather than force
|
|
106
|
+
// an industry that contradicts the category, leave it unset.
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return INDUSTRY_OF_SUB.get(subs[0]);
|
|
111
|
+
}
|
package/src/job/job.schema.ts
CHANGED
|
@@ -196,6 +196,24 @@ export const JobSchema = object({
|
|
|
196
196
|
|
|
197
197
|
jdSummary: string().optional().label("JD Summary"),
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Structured, model-extracted sections of the posting, re-organized into clean
|
|
201
|
+
* bullet points. This is our OWN unique presentation of the job (rendered as the
|
|
202
|
+
* primary, server-side page content) so the page adds value over the raw scraped
|
|
203
|
+
* description and is not penalized by Google as duplicate content. All sections
|
|
204
|
+
* optional: absent on jobs not yet (re-)enriched, and any section is `[]`/omitted
|
|
205
|
+
* when the posting genuinely has none.
|
|
206
|
+
*/
|
|
207
|
+
jobHighlights: object({
|
|
208
|
+
responsibilities: array().of(string()).optional().label("Responsibilities"),
|
|
209
|
+
requirements: array().of(string()).optional().label("Requirements"),
|
|
210
|
+
benefits: array().of(string()).optional().label("Benefits"),
|
|
211
|
+
qualifications: array().of(string()).optional().label("Qualifications"),
|
|
212
|
+
})
|
|
213
|
+
.nullable()
|
|
214
|
+
.optional()
|
|
215
|
+
.label("Job Highlights"),
|
|
216
|
+
|
|
199
217
|
canCreateAlert: boolean().optional().label("Can create alert"),
|
|
200
218
|
|
|
201
219
|
canDirectApply: boolean().optional().label("Can direct apply"),
|
|
@@ -233,6 +251,18 @@ export const JobSchema = object({
|
|
|
233
251
|
|
|
234
252
|
reports: array().of(UserIdAndCreatedAtSchema).optional().label("Reports"),
|
|
235
253
|
|
|
254
|
+
// Taxonomy fields are constants (see job-taxonomy.constant.ts), stored as slugs and
|
|
255
|
+
// translated for display. They are deliberately NOT `oneOf`-constrained here: a value
|
|
256
|
+
// outside the taxonomy must be DROPPED, never a reason to reject the posting. Yup
|
|
257
|
+
// `oneOf` fails the whole object, so one stale subCategory would cost a user their
|
|
258
|
+
// entire job on the create/edit form, and a single drifted LLM value would bin an
|
|
259
|
+
// otherwise-good enriched job.
|
|
260
|
+
//
|
|
261
|
+
// Callers sanitize instead: jobs-service's sanitizeTaxonomy() coerces unknown values
|
|
262
|
+
// to null / filters them out of the array before validation. Taxonomies change over
|
|
263
|
+
// time (values get renamed, retired, added), so stored jobs will always outlive the
|
|
264
|
+
// enum — being lenient here is what lets the taxonomy evolve without a migration
|
|
265
|
+
// gate on every write.
|
|
236
266
|
category: string().nullable().optional().label("Category"),
|
|
237
267
|
|
|
238
268
|
industry: string().nullable().optional().label("Industry"),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { array, boolean, number, object, string } from "yup";
|
|
2
|
+
import type { InferType } from "yup";
|
|
3
|
+
import { DbDefaultSchema } from "../common/common.schema.js";
|
|
4
|
+
import { PostStatus, SupportedPostStatuses } from "./post.constant.js";
|
|
5
|
+
|
|
6
|
+
export const PostSchema = object({
|
|
7
|
+
/** Headline shown in listings and at the top of the post. */
|
|
8
|
+
title: string().required().min(5).max(200).label("Title"),
|
|
9
|
+
|
|
10
|
+
/** User-friendly, unique URL identifier. */
|
|
11
|
+
slug: string().required().max(250).label("Slug"),
|
|
12
|
+
|
|
13
|
+
/** Short summary used in listings and meta description fallback. */
|
|
14
|
+
excerpt: string().max(500).optional().label("Excerpt"),
|
|
15
|
+
|
|
16
|
+
/** Rendered HTML source of truth for the post body (sanitized on write/render).
|
|
17
|
+
* Supports rich formatting markdown can't express (image size/alignment, etc). */
|
|
18
|
+
bodyHTML: string().required().min(1).label("Body (HTML)"),
|
|
19
|
+
|
|
20
|
+
/** Optional Markdown mirror of the body (portable text / search fallback).
|
|
21
|
+
* Kept for back-compat and plain-text derivation; bodyHTML is authoritative. */
|
|
22
|
+
bodyMarkdown: string().optional().label("Body (Markdown)"),
|
|
23
|
+
|
|
24
|
+
/** Cover/hero image URL. Not `.url()`-validated: the value is either an
|
|
25
|
+
* uploaded file URL served by this platform (which may be a bare host like
|
|
26
|
+
* `http://localhost:3082/...` in dev, that yup's `.url()` wrongly rejects) or
|
|
27
|
+
* an admin-pasted link. Kept as a plain string; empty ⇒ null. */
|
|
28
|
+
coverImage: string().nullable().optional().label("Cover image"),
|
|
29
|
+
|
|
30
|
+
/** Author (user id). */
|
|
31
|
+
authorId: string().required().label("Author ID"),
|
|
32
|
+
|
|
33
|
+
/** Free-form tags for filtering and related-posts. */
|
|
34
|
+
tags: array().of(string().trim().required()).default([]).label("Tags"),
|
|
35
|
+
|
|
36
|
+
/** Estimated reading time in minutes (server-derived from body length). */
|
|
37
|
+
readingTimeMinutes: number()
|
|
38
|
+
.min(0)
|
|
39
|
+
.optional()
|
|
40
|
+
.label("Reading time (minutes)"),
|
|
41
|
+
|
|
42
|
+
/** SEO overrides. */
|
|
43
|
+
seoTags: object({
|
|
44
|
+
description: string().optional().label("Description"),
|
|
45
|
+
keywords: array().of(string().required()).optional().label("Keywords"),
|
|
46
|
+
})
|
|
47
|
+
.nullable()
|
|
48
|
+
.optional()
|
|
49
|
+
.label("SEO Tags"),
|
|
50
|
+
|
|
51
|
+
/** When the post was first published (epoch ms). Server-owned. */
|
|
52
|
+
publishedAt: number().nullable().optional().label("Published at"),
|
|
53
|
+
|
|
54
|
+
/** Whether the post is featured in the blog listing. */
|
|
55
|
+
isFeatured: boolean().default(false).optional().label("Is featured"),
|
|
56
|
+
|
|
57
|
+
/** Publication state. */
|
|
58
|
+
status: string()
|
|
59
|
+
.oneOf(SupportedPostStatuses)
|
|
60
|
+
.default(PostStatus.Draft)
|
|
61
|
+
.required()
|
|
62
|
+
.label("Status"),
|
|
63
|
+
})
|
|
64
|
+
.concat(DbDefaultSchema)
|
|
65
|
+
.noUnknown()
|
|
66
|
+
.label("Post");
|
|
67
|
+
|
|
68
|
+
export type TPostSchema = InferType<typeof PostSchema>;
|