@thejob/schema 2.5.0 → 2.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thejob/schema",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
package/src/index.ts CHANGED
@@ -118,6 +118,17 @@ export * from "./skill/skill.schema.js";
118
118
  export * from "./billing/billing.schema.js";
119
119
  export * from "./billing/billing.constant.js";
120
120
 
121
+ /**
122
+ * Taxonomy schemas and constants (thejob-taxonomy-service: concepts, backbone,
123
+ * vocabularies, misses, the review queue).
124
+ *
125
+ * Constants here are `as const` arrays rather than enums; see the note at the top
126
+ * of `taxonomy.constant.ts`.
127
+ */
128
+ export * from "./taxonomy/taxonomy.schema.js";
129
+ export * from "./taxonomy/taxonomy.constant.js";
130
+ export * from "./taxonomy/taxonomy-filter.schema.js";
131
+
121
132
  /**
122
133
  * Social account schemas and constants.
123
134
  */
@@ -13,6 +13,7 @@ import { DurationSchema } from "../duration/duration.schema.js";
13
13
  import { LocationSchema } from "../location/location.schema.js";
14
14
  import { PageSchema } from "../page/page.schema.js";
15
15
  import { SkillSchema } from "../skill/skill.schema.js";
16
+ import { ConceptSchema } from "../taxonomy/taxonomy.schema.js";
16
17
  import { QuestionType } from "../question/index.js";
17
18
  import type {
18
19
  TChoiceQuestionSchema,
@@ -70,14 +71,27 @@ export const JobSchema = object({
70
71
  workMode: string().required().label("Work mode"),
71
72
 
72
73
  /**
73
- * `conceptId`/`conceptName` carry the taxonomy-service normalization. They are
74
- * picked here (not just `name`/`logo`) because SkillSchema is `.noUnknown()`:
75
- * an unpicked field that is actually PRESENT fails validation, so omitting them
76
- * would reject every normalized job.
74
+ * `name` is the skill exactly as posted. `concept` is what thejob-taxonomy-service
75
+ * resolved it to (`vocabulary: skills`), written by jobs-service, never by the
76
+ * poster: enough of the concept to filter by id and display the canonical name
77
+ * without a lookup. A copy, so it can go stale after a taxonomy release; see
78
+ * `taxonomyVersion`.
79
+ *
80
+ * Additive, never authoritative: the raw `name` is always kept, so a wrong
81
+ * match, a taxonomy release or an outage is recoverable by re-normalizing.
82
+ * `concept` is absent when the term did not resolve (a normal outcome, logged
83
+ * as a miss on the taxonomy side) or the job predates normalization, so read
84
+ * `concept?.name ?? name`.
77
85
  */
78
86
  skills: array()
79
87
  .of(
80
- SkillSchema.pick(["name", "logo", "conceptId", "conceptName"])
88
+ SkillSchema.pick(["name", "logo"])
89
+ .shape({
90
+ concept: ConceptSchema.pick(["conceptId", "vocabulary", "name", "type"])
91
+ .nullable()
92
+ .optional()
93
+ .default(undefined),
94
+ })
81
95
  .required()
82
96
  .label("Skill"),
83
97
  )
@@ -283,6 +297,15 @@ export const JobSchema = object({
283
297
  .optional()
284
298
  .label("Sub Categories"),
285
299
 
300
+ /**
301
+ * The taxonomy state `skills[].concept` was resolved against (the service's
302
+ * `taxonomyVersion`, bumped by every release and rollback); behind the current
303
+ * version means the concepts may be stale. Written by jobs-service with every
304
+ * skills write: `null` when that write could not be normalized (taxonomy down
305
+ * or unconfigured), absent on jobs that predate normalization.
306
+ */
307
+ taxonomyVersion: number().integer().min(0).nullable().optional().label("Taxonomy Version"),
308
+
286
309
  termsAccepted: boolean()
287
310
  .oneOf([true], "Accept terms before proceeding")
288
311
  .required("Accept terms before proceeding")
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The taxonomy filters: the closed vocabulary of facets each thejob-taxonomy-service
3
+ * list can filter on. The counterparts of `FeedbackFilterSchema` and
4
+ * `SkillFilterSchema`, read by the service through `parseFilter`.
5
+ *
6
+ * Every facet is a field the matching document schema declares, named as it is
7
+ * there, OR within a facet and AND across facets. Absent means "no constraint".
8
+ *
9
+ * Not facets, and absent for that reason: the reserved list params (`search`,
10
+ * `page`, `limit`, `sortBy`, `sortDirection`, `fields`) and `vocabulary`, which
11
+ * the service reserves as the SCOPE of a vocabulary's lists (a concept, node,
12
+ * miss or draft belongs to exactly one vocabulary, and a list never mixes them).
13
+ */
14
+ import { boolean, object } from "yup";
15
+ import { enumFacet, rejectUnknownKeys, tokens } from "../common/facet.schema.js";
16
+ import {
17
+ SupportedAuditStatuses,
18
+ SupportedProposalFilters,
19
+ SupportedProposalStatuses,
20
+ SupportedReleaseKinds,
21
+ SupportedReleaseStatuses,
22
+ SupportedTaxonomyNodeKinds,
23
+ SupportedUnmatchedReasons,
24
+ SupportedUnmatchedResolutions,
25
+ } from "./taxonomy.constant.js";
26
+
27
+ const ConceptFilterShape = object({
28
+ /** Type ids. Open: which types exist is the vocabulary's data. */
29
+ type: tokens("Type"),
30
+ /** Category node ids the concept is placed under (`memberships.categoryId`). */
31
+ categoryId: tokens("Category"),
32
+ /** True: only live concepts with no backbone home (the uncategorized backlog). */
33
+ unplaced: boolean().optional().label("Unplaced"),
34
+ }).label("Concept Filter");
35
+
36
+ export const ConceptFilterSchema = rejectUnknownKeys(ConceptFilterShape, () =>
37
+ Object.keys(ConceptFilterShape.fields),
38
+ );
39
+
40
+ const TaxonomyNodeFilterShape = object({
41
+ kind: enumFacet(SupportedTaxonomyNodeKinds, "Kind"),
42
+ /** Parent node ids: a node's children. */
43
+ parentId: tokens("Parent"),
44
+ }).label("Taxonomy Node Filter");
45
+
46
+ export const TaxonomyNodeFilterSchema = rejectUnknownKeys(TaxonomyNodeFilterShape, () =>
47
+ Object.keys(TaxonomyNodeFilterShape.fields),
48
+ );
49
+
50
+ const UnmatchedFilterShape = object({
51
+ resolution: enumFacet(SupportedUnmatchedResolutions, "Resolution"),
52
+ reason: enumFacet(SupportedUnmatchedReasons, "Reason"),
53
+ }).label("Unmatched Filter");
54
+
55
+ export const UnmatchedFilterSchema = rejectUnknownKeys(UnmatchedFilterShape, () =>
56
+ Object.keys(UnmatchedFilterShape.fields),
57
+ );
58
+
59
+ const ConceptProposalFilterShape = object({
60
+ status: enumFacet(SupportedProposalStatuses, "Status"),
61
+ /** A kind, or `uncategorized`: concept and placement drafts with no home yet. */
62
+ kind: enumFacet(SupportedProposalFilters, "Kind"),
63
+ }).label("Concept Proposal Filter");
64
+
65
+ export const ConceptProposalFilterSchema = rejectUnknownKeys(ConceptProposalFilterShape, () =>
66
+ Object.keys(ConceptProposalFilterShape.fields),
67
+ );
68
+
69
+ const ReleaseFilterShape = object({
70
+ kind: enumFacet(SupportedReleaseKinds, "Kind"),
71
+ status: enumFacet(SupportedReleaseStatuses, "Status"),
72
+ }).label("Release Filter");
73
+
74
+ export const ReleaseFilterSchema = rejectUnknownKeys(ReleaseFilterShape, () =>
75
+ Object.keys(ReleaseFilterShape.fields),
76
+ );
77
+
78
+ const AuditFilterShape = object({
79
+ status: enumFacet(SupportedAuditStatuses, "Status"),
80
+ }).label("Audit Filter");
81
+
82
+ export const AuditFilterSchema = rejectUnknownKeys(AuditFilterShape, () =>
83
+ Object.keys(AuditFilterShape.fields),
84
+ );
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Taxonomy vocabulary (thejob-taxonomy-service).
3
+ *
4
+ * Every value here is stored in Mongo and compared as a literal by the service,
5
+ * so the strings are data, not style. Two conventions differ from the rest of the
6
+ * package, both deliberate:
7
+ *
8
+ * 1. **`as const` arrays, not enums.** The service compares and assigns these as
9
+ * string literals (`status: 'draft'`) in hundreds of places; a TS string enum
10
+ * would make every one of them a type error. The array is the single list,
11
+ * and the type is derived from it, the same way as `SupportedSalaryCurrencies`.
12
+ * 2. **Some values keep underscores** (`spoken_language`, `new_concept`,
13
+ * `rolled_back`). They are stored as written; changing one is a data
14
+ * migration, not a rename.
15
+ */
16
+
17
+ /** Who owns a concept. Only `seed` may be rewritten by a re-seed. */
18
+ export const SupportedConceptSources = ["seed", "manual", "import"] as const;
19
+ export type ConceptSource = (typeof SupportedConceptSources)[number];
20
+
21
+ /** Who owns a backbone node or a vocabulary. Only `seed` may be rewritten by a re-seed. */
22
+ export const SupportedTaxonomySources = ["seed", "manual"] as const;
23
+ export type TaxonomySource = (typeof SupportedTaxonomySources)[number];
24
+
25
+ /** The three levels of a vocabulary's backbone, root first. */
26
+ export const SupportedTaxonomyNodeKinds = ["category", "subcategory", "group"] as const;
27
+ export type TaxonomyNodeKind = (typeof SupportedTaxonomyNodeKinds)[number];
28
+
29
+ /** Cheap rules that reject a miss before any model sees it, per vocabulary. */
30
+ export const SupportedPrefilterRules = [
31
+ "spoken_language",
32
+ "no_letters",
33
+ "url_or_email",
34
+ "fragment",
35
+ ] as const;
36
+ export type PrefilterRule = (typeof SupportedPrefilterRules)[number];
37
+
38
+ /** Triage state of a miss. */
39
+ export const SupportedUnmatchedResolutions = ["pending", "added", "rejected"] as const;
40
+ export type UnmatchedResolution = (typeof SupportedUnmatchedResolutions)[number];
41
+
42
+ /** Why a miss is not a gap: the term names several concepts and no context picked one. */
43
+ export const SupportedUnmatchedReasons = ["ambiguous"] as const;
44
+ export type UnmatchedReason = (typeof SupportedUnmatchedReasons)[number];
45
+
46
+ /**
47
+ * What approving a proposal does. `concept`: a new concept. `alias`: another name
48
+ * for an existing concept. `placement`: a home for a concept that has none.
49
+ */
50
+ export const SupportedProposalKinds = ["concept", "alias", "placement"] as const;
51
+ export type ProposalKind = (typeof SupportedProposalKinds)[number];
52
+
53
+ /** The review queue's filter: a kind, or `uncategorized` (drafts with no home yet). */
54
+ export const SupportedProposalFilters = [...SupportedProposalKinds, "uncategorized"] as const;
55
+ export type ProposalFilter = (typeof SupportedProposalFilters)[number];
56
+
57
+ export const SupportedProposalStatuses = ["draft", "approved", "discarded"] as const;
58
+ export type ProposalStatus = (typeof SupportedProposalStatuses)[number];
59
+
60
+ export const SupportedProposalMessageRoles = ["operator", "model"] as const;
61
+ export type ProposalMessageRole = (typeof SupportedProposalMessageRoles)[number];
62
+
63
+ /** The resolver's judgement of a miss against the concepts it was shown. */
64
+ export const SupportedResolverVerdicts = [
65
+ "same",
66
+ "narrower",
67
+ "broader",
68
+ "related",
69
+ "new_concept",
70
+ "out_of_scope",
71
+ ] as const;
72
+ export type ResolverVerdict = (typeof SupportedResolverVerdicts)[number];
73
+
74
+ /** A human's check of one auto-accepted key. */
75
+ export const SupportedAuditStatuses = ["pending", "correct", "wrong"] as const;
76
+ export type AuditStatus = (typeof SupportedAuditStatuses)[number];
77
+
78
+ /** Where a batch of key writes came from. */
79
+ export const SupportedReleaseKinds = ["seed", "manual", "review", "auto"] as const;
80
+ export type ReleaseKind = (typeof SupportedReleaseKinds)[number];
81
+
82
+ export const SupportedReleaseStatuses = ["open", "rolled_back"] as const;
83
+ export type ReleaseStatus = (typeof SupportedReleaseStatuses)[number];
84
+
85
+ /**
86
+ * How a term resolved. `exact` and `canonical` are deterministic key hits,
87
+ * `fuzzy` a scored search hit above the threshold, `none` no answer.
88
+ */
89
+ export const SupportedMatchTypes = ["exact", "canonical", "fuzzy", "none"] as const;
90
+ export type MatchType = (typeof SupportedMatchTypes)[number];
91
+
92
+ /** The service's maintenance jobs, as started from the API or the schedule. */
93
+ export const SupportedTaxonomyJobKinds = [
94
+ "describe-concepts",
95
+ "retype-concepts",
96
+ "generate-aliases",
97
+ "alias-backfill",
98
+ "close-gaps",
99
+ "propose-placements",
100
+ "place-unplaced",
101
+ "calibrate-placement",
102
+ "reseed",
103
+ "reindex",
104
+ "calibrate-resolver",
105
+ "golden",
106
+ "resolve-backlog",
107
+ ] as const;
108
+ export type TaxonomyJobKind = (typeof SupportedTaxonomyJobKinds)[number];
109
+
110
+ export const SupportedTaxonomyJobStates = ["running", "succeeded", "failed", "cancelled"] as const;
111
+ export type TaxonomyJobState = (typeof SupportedTaxonomyJobStates)[number];
112
+
113
+ /** `<vocabulary>_<slug>`, e.g. `skills_power-bi`. The vocabulary is the part before `_`. */
114
+ export const CONCEPT_ID_PATTERN = /^[a-z][a-z0-9-]*_[a-z0-9-]+$/;
115
+
116
+ /** Never `_`: concept ids use it to separate the vocabulary from the slug. */
117
+ export const VOCABULARY_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
118
+
119
+ /** Backbone node ids, e.g. `CAT_01_TECH`. Interpolated into search filters, so nothing else is allowed. */
120
+ export const TAXONOMY_NODE_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
121
+
122
+ /** A concept type id, e.g. `programming_language`. */
123
+ export const CONCEPT_TYPE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
124
+
125
+ /** Schedule bounds, in hours: the floor protects the AI budget, the ceiling keeps it a schedule. */
126
+ export const SCHEDULE_MIN_HOURS = 1;
127
+ export const SCHEDULE_MAX_HOURS = 24 * 30;