vet-sdk-core-ts 0.4.38 → 0.4.40

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/README.md CHANGED
@@ -7,6 +7,12 @@ An animal is the first member of its own Individual Organization, so
7
7
  human that creates it is `Organization.owner`, the controller by default;
8
8
  `RESPRSN` must never be copied into the animal member.
9
9
 
10
+ Name, birth date, exact NCBI species and optional breed are animal
11
+ demographics. Breed remains separate descriptive text. Controller-authored
12
+ demographics remain editable until a veterinarian attests them; attestation
13
+ does not rewrite the original author, and later correction creates a new
14
+ veterinarian-authored version.
15
+
10
16
  Use `buildVetChainAnimalDraftClaims(...)` while the name, exact NCBI species or
11
17
  public Card is missing. Only a draft for which
12
18
  `readVetChainAnimalOnboardingReadiness(...)` returns `ready: true` may be
@@ -222,6 +228,16 @@ The catalogue is a governed starter set, not a closed biological universe.
222
228
  Callers may retain another verified positive non-human NCBI Taxonomy identifier.
223
229
  This matters because animal keeping varies by jurisdiction and taxonomy evolves.
224
230
 
231
+ Species-language resolution is channel-neutral. Voice, WhatsApp, portal chat
232
+ and portal speech call `resolveVetChainSpeciesAlias` with the same localized
233
+ lexicon. It performs exact normalized matching only. If that fast path misses,
234
+ a server adapter implements `VetChainSpeciesTerminologyResolver` and sends text
235
+ plus locale to the authenticated terminology service. The service returns a
236
+ closed NCBI candidate set, which must pass
237
+ `validateVetChainSpeciesTerminologyCandidates`; a model such as Gemma may rank
238
+ that set but cannot add codes. `selectVetChainSpeciesTerminologyCandidate`
239
+ accepts only a code actually returned for user review and confirmation.
240
+
225
241
  Current cards are issued with `issueVetChainAnimalCard`: five jurisdiction
226
242
  digits plus `animalNumericId15 + checkDigit1`, and the matching
227
243
  `did:web:{host}:card:vetchain:{jurisdiction5}:{animal16}`. Species is separate
@@ -232,10 +248,16 @@ being repeated in the 21 printed digits.
232
248
  import {
233
249
  VetChainDomesticAnimalSpecies,
234
250
  findVetChainSpeciesByTaxonomyId,
251
+ resolveVetChainSpeciesAlias,
235
252
  } from "vet-sdk-core-ts/species";
236
253
 
237
254
  VetChainDomesticAnimalSpecies.DomesticFerret.ncbiTaxonomyId; // "9669"
238
255
  findVetChainSpeciesByTaxonomyId("9685")?.key; // "Cat"
256
+ resolveVetChainSpeciesAlias({
257
+ text: "Dog.",
258
+ locale: "en-CA",
259
+ lexicon: [{ ncbiTaxonomyId: "9615", acceptedTexts: ["dog", "it is a dog"] }],
260
+ })?.ncbiTaxonomyId; // "9615"
239
261
  ```
240
262
 
241
263
  ## Digital-twin search
@@ -11,6 +11,7 @@ export type VetChainAnimalOnboardingInput = Readonly<{
11
11
  birthYear?: number;
12
12
  gender?: 'female' | 'male' | 'other' | 'unknown';
13
13
  ncbiTaxonomyId: string;
14
+ breedLabel?: string;
14
15
  controllerEmail?: string;
15
16
  controllerTelephone?: string;
16
17
  sector?: string;
@@ -24,6 +25,7 @@ export type VetChainAnimalDraftInput = Readonly<{
24
25
  birthYear?: number;
25
26
  gender?: 'female' | 'male' | 'other' | 'unknown';
26
27
  ncbiTaxonomyId?: string;
28
+ breedLabel?: string;
27
29
  controllerEmail?: string;
28
30
  controllerTelephone?: string;
29
31
  sector?: string;
@@ -41,6 +43,7 @@ export declare const VetChainAnimalOnboardingClaimNames: Readonly<{
41
43
  readonly sameAs: "org.schema.Organization.sameAs";
42
44
  readonly memberName: "org.schema.Organization.member.name";
43
45
  readonly memberAdditionalType: "org.schema.Organization.member.additionalType";
46
+ readonly memberBreed: "org.schema.Organization.member.breed";
44
47
  readonly memberBirthDate: "org.schema.Organization.member.birthDate";
45
48
  readonly memberGender: "org.schema.Organization.member.gender";
46
49
  readonly memberRole: "org.schema.Organization.member.role";
@@ -12,6 +12,7 @@ export const VetChainAnimalOnboardingClaimNames = Object.freeze({
12
12
  sameAs: 'org.schema.Organization.sameAs',
13
13
  memberName: 'org.schema.Organization.member.name',
14
14
  memberAdditionalType: 'org.schema.Organization.member.additionalType',
15
+ memberBreed: 'org.schema.Organization.member.breed',
15
16
  memberBirthDate: 'org.schema.Organization.member.birthDate',
16
17
  memberGender: 'org.schema.Organization.member.gender',
17
18
  memberRole: 'org.schema.Organization.member.role',
@@ -37,8 +38,9 @@ export function buildVetChainAnimalDraftClaims(input) {
37
38
  throw new TypeError('vet_animal_card_species_required');
38
39
  if (cardDidWeb)
39
40
  assertCardSpecies(cardDidWeb, ncbiTaxonomyId);
40
- const birthDate = String(input.birthDate || '').trim()
41
- || (Number.isInteger(input.birthYear) ? String(input.birthYear) : '');
41
+ const birthDate = normalizePartialBirthDate(String(input.birthDate || '').trim()
42
+ || (Number.isInteger(input.birthYear) ? String(input.birthYear) : ''));
43
+ const breedLabel = String(input.breedLabel || '').trim();
42
44
  const legalName = String(input.legalName || '').trim();
43
45
  const gender = input.gender && input.gender !== 'unknown' ? input.gender : '';
44
46
  const claim = VetChainAnimalOnboardingClaimNames;
@@ -55,6 +57,7 @@ export function buildVetChainAnimalDraftClaims(input) {
55
57
  ...(ncbiTaxonomyId ? {
56
58
  [claim.memberAdditionalType]: `http://purl.obolibrary.org/obo/NCBITaxon_${ncbiTaxonomyId}`,
57
59
  } : {}),
60
+ ...(breedLabel ? { [claim.memberBreed]: breedLabel } : {}),
58
61
  [claim.memberRole]: VetChainAnimalMemberRoles.Self,
59
62
  ...(birthDate ? { [claim.memberBirthDate]: birthDate } : {}),
60
63
  ...(gender ? { [claim.memberGender]: gender } : {}),
@@ -63,6 +66,26 @@ export function buildVetChainAnimalDraftClaims(input) {
63
66
  [claim.serviceCategory]: String(input.sector || '').trim() || 'animal-care',
64
67
  });
65
68
  }
69
+ /** Normalizes ISO 8601 reduced-precision animal birth dates (year, year-month or full date). */
70
+ function normalizePartialBirthDate(value) {
71
+ if (!value)
72
+ return '';
73
+ const match = value.match(/^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/);
74
+ if (!match)
75
+ throw new TypeError('vet_animal_birth_date_invalid');
76
+ const year = Number(match[1]);
77
+ const month = match[2] ? Number(match[2]) : undefined;
78
+ const day = match[3] ? Number(match[3]) : undefined;
79
+ if (year < 1000 || year > 9999 || (month !== undefined && (month < 1 || month > 12))) {
80
+ throw new TypeError('vet_animal_birth_date_invalid');
81
+ }
82
+ if (day !== undefined) {
83
+ const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
84
+ if (day < 1 || day > lastDay)
85
+ throw new TypeError('vet_animal_birth_date_invalid');
86
+ }
87
+ return value;
88
+ }
66
89
  /** Reports whether an animal draft can be activated and issued a public Card. */
67
90
  export function readVetChainAnimalOnboardingReadiness(claims) {
68
91
  const claim = VetChainAnimalOnboardingClaimNames;
package/dist/species.d.ts CHANGED
@@ -9,6 +9,27 @@ export type VetChainSpeciesDefinition = Readonly<{
9
9
  scientificName: string;
10
10
  group: VetChainAnimalGroup;
11
11
  }>;
12
+ export declare const VETCHAIN_NCBI_TAXONOMY_CODE_SYSTEM: "https://www.ncbi.nlm.nih.gov/Taxonomy";
13
+ /** Localized exact texts supplied by a VetChain product, independently of its channel adapter. */
14
+ export type VetChainSpeciesLexiconEntry = Readonly<{
15
+ ncbiTaxonomyId: NcbiTaxonomyId;
16
+ acceptedTexts: readonly string[];
17
+ }>;
18
+ /** One reviewable result returned by the authenticated terminology service. */
19
+ export type VetChainSpeciesTerminologyCandidate = Readonly<{
20
+ codeSystem: typeof VETCHAIN_NCBI_TAXONOMY_CODE_SYSTEM;
21
+ codeValue: NcbiTaxonomyId;
22
+ display: string;
23
+ score?: number;
24
+ }>;
25
+ /** Channel-neutral boundary implemented by a server-side VetChain terminology adapter. */
26
+ export interface VetChainSpeciesTerminologyResolver {
27
+ search(input: Readonly<{
28
+ text: string;
29
+ locale: string;
30
+ maxCandidates: number;
31
+ }>): Promise<readonly VetChainSpeciesTerminologyCandidate[]>;
32
+ }
12
33
  /**
13
34
  * Governed starter catalogue for domesticated and commonly kept animals.
14
35
  *
@@ -482,3 +503,27 @@ export declare const VetChainDomesticAnimalSpeciesList: readonly Readonly<{
482
503
  export declare function findVetChainSpeciesByTaxonomyId(value: string): VetChainSpeciesDefinition | undefined;
483
504
  /** The current printed card layout can embed only NCBI Taxonomy IDs of at most five digits. */
484
505
  export declare function isVetChainSpeciesCode5Compatible(value: string): boolean;
506
+ /** Normalizes human text consistently before an exact localized alias lookup. */
507
+ export declare function normalizeVetChainSpeciesText(value: string, locale?: string): string;
508
+ /**
509
+ * Resolves only an exact normalized text configured by the product locale.
510
+ *
511
+ * Voice, WhatsApp, portal chat and portal speech must call this same helper.
512
+ * Unmatched free text belongs at the terminology boundary, not in an LLM-only
513
+ * inference path.
514
+ */
515
+ export declare function resolveVetChainSpeciesAlias(input: Readonly<{
516
+ text: string;
517
+ locale: string;
518
+ lexicon: readonly VetChainSpeciesLexiconEntry[];
519
+ }>): Readonly<{
520
+ ncbiTaxonomyId: NcbiTaxonomyId;
521
+ matchedText: string;
522
+ }> | undefined;
523
+ /** Validates the closed candidate set before it can be presented or ranked. */
524
+ export declare function validateVetChainSpeciesTerminologyCandidates(values: readonly VetChainSpeciesTerminologyCandidate[]): readonly VetChainSpeciesTerminologyCandidate[];
525
+ /**
526
+ * Selects only a code in the terminology service's reviewed candidate set.
527
+ * A Gemma/ranking service may reorder this set but cannot add a code.
528
+ */
529
+ export declare function selectVetChainSpeciesTerminologyCandidate(candidates: readonly VetChainSpeciesTerminologyCandidate[], selectedCodeValue: string): VetChainSpeciesTerminologyCandidate;
package/dist/species.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Copyright 2026 Connecting Solution & Applications Ltd under the Apache License, Version 2.0.
2
+ export const VETCHAIN_NCBI_TAXONOMY_CODE_SYSTEM = "https://www.ncbi.nlm.nih.gov/Taxonomy";
2
3
  function species(key, ncbiTaxonomyId, scientificName, group) {
3
4
  if (!/^[1-9]\d*$/.test(ncbiTaxonomyId) || ncbiTaxonomyId === "9606") {
4
5
  throw new TypeError("A species requires a positive, non-human NCBI Taxonomy identifier.");
@@ -74,3 +75,71 @@ export function findVetChainSpeciesByTaxonomyId(value) {
74
75
  export function isVetChainSpeciesCode5Compatible(value) {
75
76
  return /^[1-9]\d{0,4}$/.test(value) && value !== "9606";
76
77
  }
78
+ /** Normalizes human text consistently before an exact localized alias lookup. */
79
+ export function normalizeVetChainSpeciesText(value, locale = "en") {
80
+ const normalizedLocale = String(locale || "en").trim() || "en";
81
+ return String(value || "")
82
+ .normalize("NFD")
83
+ .replace(/[\u0300-\u036f]/g, "")
84
+ .toLocaleLowerCase(normalizedLocale)
85
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
86
+ .replace(/\s+/g, " ")
87
+ .trim();
88
+ }
89
+ /**
90
+ * Resolves only an exact normalized text configured by the product locale.
91
+ *
92
+ * Voice, WhatsApp, portal chat and portal speech must call this same helper.
93
+ * Unmatched free text belongs at the terminology boundary, not in an LLM-only
94
+ * inference path.
95
+ */
96
+ export function resolveVetChainSpeciesAlias(input) {
97
+ const normalizedInput = normalizeVetChainSpeciesText(input.text, input.locale);
98
+ if (!normalizedInput)
99
+ return undefined;
100
+ for (const entry of input.lexicon) {
101
+ assertNonHumanTaxonomyId(entry.ncbiTaxonomyId);
102
+ if (entry.acceptedTexts.some((text) => normalizeVetChainSpeciesText(text, input.locale) === normalizedInput)) {
103
+ return Object.freeze({ ncbiTaxonomyId: entry.ncbiTaxonomyId, matchedText: normalizedInput });
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ /** Validates the closed candidate set before it can be presented or ranked. */
109
+ export function validateVetChainSpeciesTerminologyCandidates(values) {
110
+ if (!Array.isArray(values) || values.length === 0 || values.length > 20) {
111
+ throw new TypeError("Species terminology must return between one and twenty candidates.");
112
+ }
113
+ const seen = new Set();
114
+ return Object.freeze(values.map((candidate) => {
115
+ if (candidate.codeSystem !== VETCHAIN_NCBI_TAXONOMY_CODE_SYSTEM) {
116
+ throw new TypeError("Species terminology candidates must use the canonical NCBI Taxonomy code system.");
117
+ }
118
+ assertNonHumanTaxonomyId(candidate.codeValue);
119
+ if (!candidate.display.trim())
120
+ throw new TypeError("A species terminology candidate requires a display label.");
121
+ if (seen.has(candidate.codeValue))
122
+ throw new TypeError("Species terminology candidates must be unique.");
123
+ if (candidate.score !== undefined && (!Number.isFinite(candidate.score) || candidate.score < 0 || candidate.score > 1)) {
124
+ throw new TypeError("A species terminology candidate score must be between zero and one.");
125
+ }
126
+ seen.add(candidate.codeValue);
127
+ return Object.freeze({ ...candidate, display: candidate.display.trim() });
128
+ }));
129
+ }
130
+ /**
131
+ * Selects only a code in the terminology service's reviewed candidate set.
132
+ * A Gemma/ranking service may reorder this set but cannot add a code.
133
+ */
134
+ export function selectVetChainSpeciesTerminologyCandidate(candidates, selectedCodeValue) {
135
+ const validated = validateVetChainSpeciesTerminologyCandidates(candidates);
136
+ const selected = validated.find((candidate) => candidate.codeValue === selectedCodeValue.trim());
137
+ if (!selected)
138
+ throw new TypeError("The selected species was not returned by the terminology service.");
139
+ return selected;
140
+ }
141
+ function assertNonHumanTaxonomyId(value) {
142
+ if (!/^[1-9]\d*$/.test(value) || value === "9606") {
143
+ throw new TypeError("A species requires a positive, non-human NCBI Taxonomy identifier.");
144
+ }
145
+ }
@@ -21,6 +21,16 @@ const draft = buildVetChainAnimalDraftClaims({
21
21
  controllerTelephone: '+12365162385',
22
22
  })
23
23
 
24
+ // Optional breed text stays separate from the governed NCBI species. The
25
+ // birth date may safely retain reduced year-month precision.
26
+ buildVetChainAnimalDraftClaims({
27
+ subjectId: crypto.randomUUID(),
28
+ controllerTelephone: '+12365162385',
29
+ ncbiTaxonomyId: '9615',
30
+ breedLabel: 'Beagle',
31
+ birthDate: '2022-01',
32
+ })
33
+
24
34
  // Step 2. The draft has member=SELF/ONESELF and owner=controller.
25
35
  draft['org.schema.Organization.member.role'] // ONESELF
26
36
  draft['org.schema.Organization.owner.telephone'] // verified controller
@@ -35,3 +45,10 @@ taxonomy, issues the public Card, and resubmits it under the same private
35
45
  `subjectId`. GW VET alone performs the `pending -> active` transition. A draft
36
46
  does not need a clinical `Composition`; the active clinical index is a
37
47
  separate document lifecycle.
48
+
49
+ The controller is the author of demographics supplied during this draft. A
50
+ veterinarian later verifies them as attester. That verification locks the
51
+ controller-facing edit path; a correction is a new version authored and
52
+ attested by an authorized veterinarian, never a rewrite of the earlier author.
53
+ Veterinary identity evidence publishes only protected proof and lifecycle
54
+ metadata to the ledger, not raw identity documents or private demographics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-sdk-core-ts",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
4
4
  "description": "Browser-safe VetChain core contracts and governed animal species identifiers",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",