@thejob/schema 2.4.1 → 2.6.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/CLAUDE.md +6 -0
- package/dist/index.cjs +634 -148
- package/dist/index.d.cts +592 -1
- package/dist/index.d.ts +592 -1
- package/dist/index.js +587 -146
- package/package.json +1 -1
- package/src/index.ts +11 -0
- package/src/marketing/marketing.schema.ts +17 -0
- package/src/taxonomy/taxonomy-filter.schema.ts +84 -0
- package/src/taxonomy/taxonomy.constant.ts +127 -0
- package/src/taxonomy/taxonomy.schema.ts +404 -0
package/package.json
CHANGED
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
|
*/
|
|
@@ -300,6 +300,23 @@ export const CampaignSchema = object({
|
|
|
300
300
|
* state transition, so it belongs in the state.
|
|
301
301
|
*/
|
|
302
302
|
dryRun: boolean().optional().default(true).label("Dry Run"),
|
|
303
|
+
/**
|
|
304
|
+
* Re-eligibility: when someone this campaign already mailed may get it again.
|
|
305
|
+
*
|
|
306
|
+
* Absent: on every run they match (job alerts, digests). `0`: once per
|
|
307
|
+
* person. `N`: again only after N days. Opt-in, because most recurring
|
|
308
|
+
* campaigns exist to mail the same people repeatedly.
|
|
309
|
+
*/
|
|
310
|
+
resendAfterDays: number()
|
|
311
|
+
.integer()
|
|
312
|
+
.min(0)
|
|
313
|
+
.max(365)
|
|
314
|
+
// `null` clears it back to "every run": a PATCH can only `$set`, and JSON
|
|
315
|
+
// has no `undefined` to send.
|
|
316
|
+
.nullable()
|
|
317
|
+
.optional()
|
|
318
|
+
.default(undefined)
|
|
319
|
+
.label("Resend After (days)"),
|
|
303
320
|
maxRecipients: number()
|
|
304
321
|
.integer()
|
|
305
322
|
.min(1)
|
|
@@ -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;
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { array, boolean, number, object, string } from "yup";
|
|
2
|
+
import {
|
|
3
|
+
CONCEPT_ID_PATTERN,
|
|
4
|
+
CONCEPT_TYPE_PATTERN,
|
|
5
|
+
SCHEDULE_MAX_HOURS,
|
|
6
|
+
SCHEDULE_MIN_HOURS,
|
|
7
|
+
SupportedAuditStatuses,
|
|
8
|
+
SupportedConceptSources,
|
|
9
|
+
SupportedPrefilterRules,
|
|
10
|
+
SupportedProposalKinds,
|
|
11
|
+
SupportedProposalMessageRoles,
|
|
12
|
+
SupportedProposalStatuses,
|
|
13
|
+
SupportedReleaseKinds,
|
|
14
|
+
SupportedReleaseStatuses,
|
|
15
|
+
SupportedResolverVerdicts,
|
|
16
|
+
SupportedTaxonomyJobKinds,
|
|
17
|
+
SupportedTaxonomyNodeKinds,
|
|
18
|
+
SupportedTaxonomySources,
|
|
19
|
+
SupportedUnmatchedReasons,
|
|
20
|
+
SupportedUnmatchedResolutions,
|
|
21
|
+
TAXONOMY_NODE_ID_PATTERN,
|
|
22
|
+
VOCABULARY_ID_PATTERN,
|
|
23
|
+
} from "./taxonomy.constant.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The documents thejob-taxonomy-service stores and serves.
|
|
27
|
+
*
|
|
28
|
+
* Timestamps are epoch milliseconds (numbers), and ids are the service's own
|
|
29
|
+
* (`conceptId`, `nodeId`, ...), so none of these extend `DbDefaultSchema`.
|
|
30
|
+
*
|
|
31
|
+
* Optional fields really are absent on some documents, which is why they are
|
|
32
|
+
* `.optional()` rather than defaulted: several are resume markers or audit
|
|
33
|
+
* stamps whose ABSENCE carries meaning ("not looked at yet").
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const conceptId = () => string().matches(CONCEPT_ID_PATTERN, "conceptId must look like <vocabulary>_<slug>");
|
|
37
|
+
const vocabularyId = () => string().matches(VOCABULARY_ID_PATTERN, "vocabulary must match [a-z][a-z0-9-]{0,31}");
|
|
38
|
+
const nodeId = () => string().matches(TAXONOMY_NODE_ID_PATTERN, "node ids look like CAT_01_TECH");
|
|
39
|
+
const strings = () => array(string().trim().min(1).required());
|
|
40
|
+
|
|
41
|
+
// ─── Vocabularies ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** One concept type a vocabulary allows, e.g. `programming_language`. */
|
|
44
|
+
export const VocabularyTypeSchema = object({
|
|
45
|
+
id: string().matches(CONCEPT_TYPE_PATTERN, "type ids match [a-z][a-z0-9_]*").required().label("Type ID"),
|
|
46
|
+
/** Shown to the model when it classifies a concept; include examples. */
|
|
47
|
+
description: string().defined().label("Description"),
|
|
48
|
+
}).label("Vocabulary Type");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A kind of concept the service holds (skills, languages, roles). Adding one is
|
|
52
|
+
* data (an API call), never a code change: every prompt is built from these
|
|
53
|
+
* fields.
|
|
54
|
+
*/
|
|
55
|
+
export const VocabularySchema = object({
|
|
56
|
+
vocabularyId: vocabularyId().required().label("Vocabulary ID"),
|
|
57
|
+
name: string().trim().min(1).required().label("Name"),
|
|
58
|
+
/** What one concept is called in prompts and messages: "skill", "role". */
|
|
59
|
+
noun: string().trim().min(1).required().label("Noun"),
|
|
60
|
+
nounPlural: string().trim().min(1).required().label("Noun (plural)"),
|
|
61
|
+
/** What belongs in this vocabulary, for prompts. */
|
|
62
|
+
description: string().trim().min(1).required().label("Description"),
|
|
63
|
+
/** Where its terms are written, for the "swap it in" test: "a job posting". */
|
|
64
|
+
appearsIn: string().trim().min(1).required().label("Appears In"),
|
|
65
|
+
/** Things that are NOT concepts of this vocabulary, for prompts. */
|
|
66
|
+
outOfScope: string().trim().min(1).required().label("Out of Scope"),
|
|
67
|
+
types: array(VocabularyTypeSchema.required())
|
|
68
|
+
.min(1)
|
|
69
|
+
.required()
|
|
70
|
+
.test("unique-type-ids", "type ids must be unique", (types) =>
|
|
71
|
+
!types || new Set(types.map((t) => t.id)).size === types.length,
|
|
72
|
+
)
|
|
73
|
+
.label("Types"),
|
|
74
|
+
/** The type a concept gets when nothing more specific is known. One of `types`. */
|
|
75
|
+
defaultType: string()
|
|
76
|
+
.required()
|
|
77
|
+
.test("default-type-listed", "defaultType must be one of the type ids", function (value) {
|
|
78
|
+
const types = (this.parent as { types?: { id: string }[] }).types ?? [];
|
|
79
|
+
return types.some((t) => t.id === value);
|
|
80
|
+
})
|
|
81
|
+
.label("Default Type"),
|
|
82
|
+
prefilter: array(string().oneOf(SupportedPrefilterRules).required()).default([]).label("Prefilter"),
|
|
83
|
+
/**
|
|
84
|
+
* Words that qualify a term without changing what it names ("fluent",
|
|
85
|
+
* "native"), dropped from its canonical lookup form in THIS vocabulary only.
|
|
86
|
+
*/
|
|
87
|
+
qualifiers: strings().optional().label("Qualifiers"),
|
|
88
|
+
/** Extra instructions per model task; each is optional. */
|
|
89
|
+
guidance: object({
|
|
90
|
+
describe: string().optional(),
|
|
91
|
+
classify: string().optional(),
|
|
92
|
+
aliases: string().optional(),
|
|
93
|
+
verify: string().optional(),
|
|
94
|
+
place: string().optional(),
|
|
95
|
+
}).label("Guidance"),
|
|
96
|
+
source: string().oneOf(SupportedTaxonomySources).required().label("Source"),
|
|
97
|
+
createdAt: number().required().label("Created At"),
|
|
98
|
+
updatedAt: number().required().label("Updated At"),
|
|
99
|
+
}).label("Vocabulary");
|
|
100
|
+
|
|
101
|
+
// ─── Backbone ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One node of a vocabulary's 3-level backbone (category > subcategory > group),
|
|
105
|
+
* stored flat with `parentId` and `path` so any level can be read directly.
|
|
106
|
+
*/
|
|
107
|
+
export const TaxonomyNodeSchema = object({
|
|
108
|
+
nodeId: nodeId().required().label("Node ID"),
|
|
109
|
+
vocabulary: vocabularyId().required().label("Vocabulary"),
|
|
110
|
+
kind: string().oneOf(SupportedTaxonomyNodeKinds).required().label("Kind"),
|
|
111
|
+
name: string().trim().min(1).required().label("Name"),
|
|
112
|
+
/** Groups carry a description; categories and subcategories usually do not. */
|
|
113
|
+
description: string().optional().label("Description"),
|
|
114
|
+
aliases: strings().default([]).label("Aliases"),
|
|
115
|
+
/** null for categories (the roots). */
|
|
116
|
+
parentId: string().nullable().defined().label("Parent ID"),
|
|
117
|
+
/** Ancestor ids from the root down to and including this node. */
|
|
118
|
+
path: array(string().required()).default([]).label("Path"),
|
|
119
|
+
source: string().oneOf(SupportedTaxonomySources).optional().label("Source"),
|
|
120
|
+
createdAt: number().required().label("Created At"),
|
|
121
|
+
updatedAt: number().required().label("Updated At"),
|
|
122
|
+
}).label("Taxonomy Node");
|
|
123
|
+
|
|
124
|
+
// ─── Concepts ────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where a concept sits in its vocabulary's backbone. A concept may have several
|
|
128
|
+
* (the same name filed under several categories merges into one concept) or none
|
|
129
|
+
* (live but uncategorized, waiting for a placement).
|
|
130
|
+
*/
|
|
131
|
+
export const ConceptMembershipSchema = object({
|
|
132
|
+
categoryId: string().required().label("Category ID"),
|
|
133
|
+
categoryName: string().required().label("Category Name"),
|
|
134
|
+
subcategoryId: string().required().label("Subcategory ID"),
|
|
135
|
+
subcategoryName: string().required().label("Subcategory Name"),
|
|
136
|
+
groupId: string().required().label("Group ID"),
|
|
137
|
+
groupName: string().required().label("Group Name"),
|
|
138
|
+
/** The source record this membership came from; '' when written by hand. */
|
|
139
|
+
sourceId: string().default("").label("Source ID"),
|
|
140
|
+
}).label("Concept Membership");
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A canonical concept: what `/normalize` resolves free text onto.
|
|
144
|
+
*
|
|
145
|
+
* Which keys a concept answers to is NOT here: the service keeps them in its
|
|
146
|
+
* own `matchKeys` store. `aliases` is alias TEXT for people to read.
|
|
147
|
+
*/
|
|
148
|
+
export const ConceptSchema = object({
|
|
149
|
+
/** `<vocabulary>_<slug>`, e.g. `skills_power-bi`. Primary key. */
|
|
150
|
+
conceptId: conceptId().required().label("Concept ID"),
|
|
151
|
+
vocabulary: vocabularyId().required().label("Vocabulary"),
|
|
152
|
+
name: string().trim().min(1).max(200).required().label("Name"),
|
|
153
|
+
/** One of the vocabulary's type ids; checked against the vocabulary by the service. */
|
|
154
|
+
type: string().trim().matches(CONCEPT_TYPE_PATTERN, "type must be a type id").required().label("Type"),
|
|
155
|
+
description: string().trim().max(2000).default("").label("Description"),
|
|
156
|
+
aliases: strings().default([]).label("Aliases"),
|
|
157
|
+
memberships: array(ConceptMembershipSchema.required()).default([]).label("Memberships"),
|
|
158
|
+
/** Always concepts of the same vocabulary. */
|
|
159
|
+
relatedConceptIds: strings().default([]).label("Related Concepts"),
|
|
160
|
+
source: string().oneOf(SupportedConceptSources).required().label("Source"),
|
|
161
|
+
createdAt: number().required().label("Created At"),
|
|
162
|
+
updatedAt: number().required().label("Updated At"),
|
|
163
|
+
/** Resume marker of `retype-concepts`: set once the type was examined. */
|
|
164
|
+
retypedAt: number().optional().label("Retyped At"),
|
|
165
|
+
/** Resume marker of `generate-aliases`: set even when no alias was added. */
|
|
166
|
+
aliasedAt: number().optional().label("Aliased At"),
|
|
167
|
+
/** Set while `description` is a stand-in; removed once a real one is written. */
|
|
168
|
+
descriptionPending: boolean().isTrue().optional().label("Description Pending"),
|
|
169
|
+
}).label("Concept");
|
|
170
|
+
|
|
171
|
+
// ─── Misses ──────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A term callers sent that the taxonomy could not resolve: the ranked work list
|
|
175
|
+
* for growing it. Never expired; `resolution` closes the loop.
|
|
176
|
+
*/
|
|
177
|
+
export const UnmatchedTermSchema = object({
|
|
178
|
+
vocabulary: vocabularyId().required().label("Vocabulary"),
|
|
179
|
+
/** The raw input as first received. */
|
|
180
|
+
term: string().required().label("Term"),
|
|
181
|
+
/** The dedupe key, so "React JS" and "react.js" share a row. */
|
|
182
|
+
normalizedTerm: string().required().label("Normalized Term"),
|
|
183
|
+
count: number().integer().min(0).required().label("Count"),
|
|
184
|
+
firstSeenAt: number().required().label("First Seen At"),
|
|
185
|
+
lastSeenAt: number().required().label("Last Seen At"),
|
|
186
|
+
/** Which callers sent it, capped. */
|
|
187
|
+
sources: array(
|
|
188
|
+
object({
|
|
189
|
+
service: string().required(),
|
|
190
|
+
context: string().optional(),
|
|
191
|
+
}).required(),
|
|
192
|
+
)
|
|
193
|
+
.default([])
|
|
194
|
+
.label("Sources"),
|
|
195
|
+
resolution: string().oneOf(SupportedUnmatchedResolutions).required().label("Resolution"),
|
|
196
|
+
resolvedConceptId: string().optional().label("Resolved Concept"),
|
|
197
|
+
/** `ambiguous`: the concept exists; the caller gave no context that picked one. */
|
|
198
|
+
reason: string().oneOf(SupportedUnmatchedReasons).optional().label("Reason"),
|
|
199
|
+
/** The concepts an ambiguous term could mean. */
|
|
200
|
+
candidates: array(string().required()).optional().label("Candidates"),
|
|
201
|
+
/** Who rejected it when it was not a human: `prefilter:<rule>` or `model`. */
|
|
202
|
+
rejectedBy: string().optional().label("Rejected By"),
|
|
203
|
+
}).label("Unmatched Term");
|
|
204
|
+
|
|
205
|
+
// ─── Proposals (the review queue) ────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/** A scored backbone group a draft could be placed in. */
|
|
208
|
+
export const ProposalCandidateSchema = object({
|
|
209
|
+
groupId: string().required(),
|
|
210
|
+
groupName: string().required(),
|
|
211
|
+
subcategoryId: string().required(),
|
|
212
|
+
subcategoryName: string().required(),
|
|
213
|
+
categoryId: string().required(),
|
|
214
|
+
categoryName: string().required(),
|
|
215
|
+
score: number().required(),
|
|
216
|
+
nearestMembers: array(string().required()).default([]),
|
|
217
|
+
}).label("Proposal Candidate");
|
|
218
|
+
|
|
219
|
+
/** One turn of a draft's refinement chat. */
|
|
220
|
+
export const ProposalMessageSchema = object({
|
|
221
|
+
role: string().oneOf(SupportedProposalMessageRoles).required(),
|
|
222
|
+
/** The operator's instruction, or the model's note on what it changed and why. */
|
|
223
|
+
content: string().defined(),
|
|
224
|
+
/** Which model answered. Absent on operator turns. */
|
|
225
|
+
model: string().optional(),
|
|
226
|
+
at: number().required(),
|
|
227
|
+
}).label("Proposal Message");
|
|
228
|
+
|
|
229
|
+
/** A home that does not exist yet, named level by level. "" means no opinion on that level. */
|
|
230
|
+
export const SuggestedHomeSchema = object({
|
|
231
|
+
categoryName: string().defined(),
|
|
232
|
+
subcategoryName: string().defined(),
|
|
233
|
+
groupName: string().defined(),
|
|
234
|
+
}).label("Suggested Home");
|
|
235
|
+
|
|
236
|
+
/** The resolver's verdict on the existing concepts it was shown for a miss. */
|
|
237
|
+
export const ResolverVerificationSchema = object({
|
|
238
|
+
verdict: string().oneOf(SupportedResolverVerdicts).required(),
|
|
239
|
+
/** The shown concept the verdict is about; null for `new_concept` / `out_of_scope`. */
|
|
240
|
+
conceptId: string().nullable().defined(),
|
|
241
|
+
evidence: string().defined(),
|
|
242
|
+
/** What the judge took the term to mean, abbreviations expanded. */
|
|
243
|
+
means: string().optional(),
|
|
244
|
+
/** For `same`: did the independent confirm step agree? */
|
|
245
|
+
consistent: boolean().optional(),
|
|
246
|
+
confirm: object({
|
|
247
|
+
answer: string().oneOf(["yes", "no"] as const).required(),
|
|
248
|
+
evidence: string().defined(),
|
|
249
|
+
})
|
|
250
|
+
.optional()
|
|
251
|
+
.default(undefined),
|
|
252
|
+
}).label("Resolver Verification");
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A draft between a miss and the taxonomy. Nothing in a draft is matchable;
|
|
256
|
+
* approving it is the only way it reaches the concepts.
|
|
257
|
+
*/
|
|
258
|
+
export const ConceptProposalSchema = object({
|
|
259
|
+
proposalId: string().required().label("Proposal ID"),
|
|
260
|
+
vocabulary: vocabularyId().required().label("Vocabulary"),
|
|
261
|
+
/** The miss this closes; `normalizedTerm` links back to its unmatched row. */
|
|
262
|
+
term: string().required().label("Term"),
|
|
263
|
+
normalizedTerm: string().required().label("Normalized Term"),
|
|
264
|
+
/** Demand at proposal time, so the queue ranks without a join. */
|
|
265
|
+
count: number().integer().min(0).required().label("Count"),
|
|
266
|
+
/** Other misses folded into this draft because they name the same new concept. */
|
|
267
|
+
mergedTerms: array(
|
|
268
|
+
object({
|
|
269
|
+
term: string().required(),
|
|
270
|
+
normalizedTerm: string().required(),
|
|
271
|
+
count: number().integer().min(0).required(),
|
|
272
|
+
}).required(),
|
|
273
|
+
)
|
|
274
|
+
.optional()
|
|
275
|
+
.label("Merged Terms"),
|
|
276
|
+
/** Absent on drafts that predate the field: read as `concept`. */
|
|
277
|
+
kind: string().oneOf(SupportedProposalKinds).optional().label("Kind"),
|
|
278
|
+
/** For `alias`: the concept the term names. For `placement`: the concept to place. */
|
|
279
|
+
targetConceptId: string().optional().label("Target Concept"),
|
|
280
|
+
targetName: string().optional().label("Target Name"),
|
|
281
|
+
|
|
282
|
+
// The draft concept, editable until approved. Empty strings are valid here:
|
|
283
|
+
// an alias draft carries no draft concept of its own.
|
|
284
|
+
name: string().defined().label("Name"),
|
|
285
|
+
type: string().defined().label("Type"),
|
|
286
|
+
description: string().defined().label("Description"),
|
|
287
|
+
aliases: array(string().required()).default([]).label("Aliases"),
|
|
288
|
+
/** Empty when no group fits: approved that way, the concept goes live uncategorized. */
|
|
289
|
+
memberships: array(ConceptMembershipSchema.required()).default([]).label("Memberships"),
|
|
290
|
+
suggestedHome: SuggestedHomeSchema.nullable().optional().default(undefined).label("Suggested Home"),
|
|
291
|
+
/** Resolved to concepts that exist; names that matched nothing are dropped. */
|
|
292
|
+
relatedConceptIds: array(string().required()).default([]).label("Related Concepts"),
|
|
293
|
+
|
|
294
|
+
/** Every placement candidate considered, best first. */
|
|
295
|
+
candidates: array(ProposalCandidateSchema.required()).default([]).label("Candidates"),
|
|
296
|
+
needsReview: boolean().required().label("Needs Review"),
|
|
297
|
+
reason: string().optional().label("Reason"),
|
|
298
|
+
verification: ResolverVerificationSchema.optional().default(undefined).label("Verification"),
|
|
299
|
+
/** What the auto-accept gate decided for an alias verdict. */
|
|
300
|
+
autoAccept: object({
|
|
301
|
+
accepted: boolean().required(),
|
|
302
|
+
reason: string().optional(),
|
|
303
|
+
tier: string().optional(),
|
|
304
|
+
calibrationId: string().optional(),
|
|
305
|
+
releaseId: string().optional(),
|
|
306
|
+
})
|
|
307
|
+
.optional()
|
|
308
|
+
.default(undefined)
|
|
309
|
+
.label("Auto Accept"),
|
|
310
|
+
|
|
311
|
+
/** Which model drafted it. */
|
|
312
|
+
model: string().defined().label("Model"),
|
|
313
|
+
/** The refinement conversation, oldest first. */
|
|
314
|
+
messages: array(ProposalMessageSchema.required()).default([]).label("Messages"),
|
|
315
|
+
status: string().oneOf(SupportedProposalStatuses).required().label("Status"),
|
|
316
|
+
/** Set when approved: the concept this became. */
|
|
317
|
+
resolvedConceptId: string().optional().label("Resolved Concept"),
|
|
318
|
+
createdAt: number().required().label("Created At"),
|
|
319
|
+
updatedAt: number().required().label("Updated At"),
|
|
320
|
+
}).label("Concept Proposal");
|
|
321
|
+
|
|
322
|
+
// ─── Releases and audits ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/** A group of key writes that is undone together. Every key write belongs to one. */
|
|
325
|
+
export const ReleaseSchema = object({
|
|
326
|
+
releaseId: string().required().label("Release ID"),
|
|
327
|
+
kind: string().oneOf(SupportedReleaseKinds).required().label("Kind"),
|
|
328
|
+
status: string().oneOf(SupportedReleaseStatuses).required().label("Status"),
|
|
329
|
+
createdBy: string().required().label("Created By"),
|
|
330
|
+
createdAt: number().required().label("Created At"),
|
|
331
|
+
note: string().optional().label("Note"),
|
|
332
|
+
rolledBackAt: number().optional().label("Rolled Back At"),
|
|
333
|
+
rolledBackBy: string().optional().label("Rolled Back By"),
|
|
334
|
+
}).label("Release");
|
|
335
|
+
|
|
336
|
+
/** A sampled auto-accepted key, waiting for (or carrying) a human's verdict. */
|
|
337
|
+
export const AuditSchema = object({
|
|
338
|
+
auditId: string().required().label("Audit ID"),
|
|
339
|
+
releaseId: string().required().label("Release ID"),
|
|
340
|
+
key: string().required().label("Key"),
|
|
341
|
+
term: string().required().label("Term"),
|
|
342
|
+
conceptId: string().required().label("Concept ID"),
|
|
343
|
+
tier: string().optional().label("Tier"),
|
|
344
|
+
status: string().oneOf(SupportedAuditStatuses).required().label("Status"),
|
|
345
|
+
createdAt: number().required().label("Created At"),
|
|
346
|
+
resolvedAt: number().optional().label("Resolved At"),
|
|
347
|
+
resolvedBy: string().optional().label("Resolved By"),
|
|
348
|
+
}).label("Audit");
|
|
349
|
+
|
|
350
|
+
// ─── Operations ──────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Operator-editable runtime settings and kill switches. One document; every
|
|
354
|
+
* replica obeys it within seconds.
|
|
355
|
+
*/
|
|
356
|
+
export const ResolverSettingsSchema = object({
|
|
357
|
+
autoAccept: object({
|
|
358
|
+
/** Stop switch for WRITING auto keys. Off until a calibration passes. */
|
|
359
|
+
enabled: boolean().required(),
|
|
360
|
+
/** Minimum calibrated precision (Wilson 95% lower bound) for a tier to auto-accept. */
|
|
361
|
+
target: number().min(0.5).lessThan(1).required(),
|
|
362
|
+
maxPerRun: number().integer().min(0).required(),
|
|
363
|
+
maxPerDay: number().integer().min(0).required(),
|
|
364
|
+
/** Share of each auto release sampled into the audit queue (0..1). */
|
|
365
|
+
auditRate: number().min(0).max(1).required(),
|
|
366
|
+
}).label("Auto Accept"),
|
|
367
|
+
/** Stop switch for SERVING auto keys: false hides them from lookups at once. */
|
|
368
|
+
serveAutoKeys: boolean().required().label("Serve Auto Keys"),
|
|
369
|
+
/** Minimum Meilisearch ranking score (0..1) for a fuzzy hit to be considered. */
|
|
370
|
+
fuzzyMinScore: number().min(0).max(1).required().label("Fuzzy Min Score"),
|
|
371
|
+
/** Per-client limit on public search, per minute per replica. 0 disables it. */
|
|
372
|
+
searchRateLimitPerMin: number().integer().min(0).required().label("Search Rate Limit"),
|
|
373
|
+
/** Master switch for scheduled maintenance jobs. */
|
|
374
|
+
scheduledJobs: boolean().required().label("Scheduled Jobs"),
|
|
375
|
+
updatedAt: number().optional().label("Updated At"),
|
|
376
|
+
updatedBy: string().optional().label("Updated By"),
|
|
377
|
+
/** Why auto-accept was last switched off automatically. */
|
|
378
|
+
trippedReason: string().optional().label("Tripped Reason"),
|
|
379
|
+
}).label("Resolver Settings");
|
|
380
|
+
|
|
381
|
+
/** How often one maintenance job runs on the schedule. */
|
|
382
|
+
export const TaxonomyScheduleSchema = object({
|
|
383
|
+
kind: string().oneOf(SupportedTaxonomyJobKinds).required().label("Job"),
|
|
384
|
+
enabled: boolean().required().label("Enabled"),
|
|
385
|
+
everyHours: number().min(SCHEDULE_MIN_HOURS).max(SCHEDULE_MAX_HOURS).required().label("Every (hours)"),
|
|
386
|
+
/** Last time the scheduler fired this job. */
|
|
387
|
+
lastRunAt: number().optional().label("Last Run At"),
|
|
388
|
+
updatedAt: number().required().label("Updated At"),
|
|
389
|
+
}).label("Taxonomy Schedule");
|
|
390
|
+
|
|
391
|
+
// ─── Normalize ───────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Optional caller context for `/normalize`, used ONLY to choose between the
|
|
395
|
+
* meanings of an ambiguous term. It never changes an unambiguous answer.
|
|
396
|
+
*/
|
|
397
|
+
export const NormalizeContextSchema = object({
|
|
398
|
+
/** The title of the record the terms come from. */
|
|
399
|
+
title: string().max(200).optional(),
|
|
400
|
+
/** Backbone node ids (category, subcategory or group). */
|
|
401
|
+
categoryIds: array(nodeId().required()).max(10).optional(),
|
|
402
|
+
/** Other terms from the same record, in the same vocabulary. */
|
|
403
|
+
siblings: array(string().max(200).required()).max(50).optional(),
|
|
404
|
+
}).label("Normalize Context");
|