@thejob/schema 2.0.5 → 2.0.7

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.
@@ -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
+ }
@@ -251,6 +251,18 @@ export const JobSchema = object({
251
251
 
252
252
  reports: array().of(UserIdAndCreatedAtSchema).optional().label("Reports"),
253
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.
254
266
  category: string().nullable().optional().label("Category"),
255
267
 
256
268
  industry: string().nullable().optional().label("Industry"),
@@ -53,11 +53,27 @@ export const PageSchema = object()
53
53
  .nullable()
54
54
  .default(null)
55
55
  .label("Logo"),
56
+ // Taxonomy (see job-taxonomy.constant.ts): category -> industry -> subCategory,
57
+ // stored as slugs and translated for display. As on JobSchema, these are NOT
58
+ // `oneOf`-constrained: a value that drifts out of the taxonomy must be dropped,
59
+ // never a reason to reject the whole page. `categories` is the top level (kept
60
+ // required for backward compatibility); `industries` and `subCategories` are the
61
+ // deeper levels the tree picker fills in when a leaf auto-selects its parents.
56
62
  categories: array()
57
63
  .of(string().trim().required())
58
64
  .required()
59
65
  .min(1)
60
66
  .label("Categories"),
67
+ industries: array()
68
+ .of(string().trim().required())
69
+ .optional()
70
+ .default([])
71
+ .label("Industries"),
72
+ subCategories: array()
73
+ .of(string().trim().required())
74
+ .optional()
75
+ .default([])
76
+ .label("Sub Categories"),
61
77
  socialAccounts: array()
62
78
  .of(SocialAccountSchema)
63
79
  .optional()