@adobe/spacecat-shared-utils 1.50.8 → 1.51.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [@adobe/spacecat-shared-utils-v1.51.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-utils-v1.51.0...@adobe/spacecat-shared-utils-v1.51.1) (2025-09-25)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * remove unnecessary logs to reduce Coralogix usage ([#947](https://github.com/adobe/spacecat-shared/issues/947)) ([c93fa4f](https://github.com/adobe/spacecat-shared/commit/c93fa4f69238106caa0f8150df029e4535c99e39))
7
+
8
+ # [@adobe/spacecat-shared-utils-v1.51.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-utils-v1.50.8...@adobe/spacecat-shared-utils-v1.51.0) (2025-09-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * schema + file read/write for llmo config ([#981](https://github.com/adobe/spacecat-shared/issues/981)) ([93b0aae](https://github.com/adobe/spacecat-shared/commit/93b0aae0daeb1a81c6914d21b30f39b448784599))
14
+
1
15
  # [@adobe/spacecat-shared-utils-v1.50.8](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-utils-v1.50.7...@adobe/spacecat-shared-utils-v1.50.8) (2025-09-20)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-utils",
3
- "version": "1.50.8",
3
+ "version": "1.51.1",
4
4
  "description": "Shared modules of the Spacecat Services - utils",
5
5
  "type": "module",
6
6
  "engines": {
@@ -54,6 +54,7 @@
54
54
  "@json2csv/plainjs": "7.0.6",
55
55
  "aws-xray-sdk": "3.10.3",
56
56
  "date-fns": "4.1.0",
57
- "validator": "^13.15.15"
57
+ "validator": "^13.15.15",
58
+ "zod": "^4.1.11"
58
59
  }
59
60
  }
package/src/index.js CHANGED
@@ -94,3 +94,6 @@ export {
94
94
  export { detectAEMVersion, DELIVERY_TYPES } from './aem.js';
95
95
 
96
96
  export { determineAEMCSPageId, getPageEditUrl } from './aem-content-api-utils.js';
97
+
98
+ export * as llmoConfig from './llmo-config.js';
99
+ export * as schemas from './schemas.js';
@@ -0,0 +1,119 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
14
+ import { llmoConfig } from './schemas.js';
15
+
16
+ /**
17
+ * @import { S3Client } from "@aws-sdk/client-s3"
18
+ * @import { LLMOConfig } from "./schemas.js"
19
+ */
20
+
21
+ /**
22
+ * @param {string} siteId The ID of the site to get the config directory for.
23
+ * @returns {string} The configuration directory path for the given site ID.
24
+ */
25
+ export function lmmoConfigDir(siteId) {
26
+ return `config/llmo/${siteId}`;
27
+ }
28
+
29
+ /**
30
+ * @param {string} siteId The ID of the site to get the latest config file path for.
31
+ * @returns {string} The latest configuration file path for the given site ID.
32
+ */
33
+ export function llmoConfigPath(siteId) {
34
+ return `${lmmoConfigDir(siteId)}/lmmo-config.json`;
35
+ }
36
+
37
+ /**
38
+ * Returns the default LLMO configuration.
39
+ * @returns {LLMOConfig} The default configuration object.
40
+ */
41
+ export function defaultConfig() {
42
+ return {
43
+ entities: {},
44
+ brands: {
45
+ aliases: [],
46
+ },
47
+ competitors: {
48
+ competitors: [],
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Reads the LLMO configuration for a given site.
55
+ * Returns an empty configuration if the configuration does not exist.
56
+ *
57
+ * @param {string} sideId The ID of the site.
58
+ * @param {S3Client} s3Client The S3 client to use for reading the configuration.
59
+ * @param {object} [options]
60
+ * @param {string} [options.version] Optional version ID of the configuration to read.
61
+ * Defaults to the latest version.
62
+ * @param {string} [options.s3Bucket] Optional S3 bucket name.
63
+ * @returns {Promise<{config: LLMOConfig, exists: boolean}>} The configuration object and
64
+ * a flag indicating if it existed.
65
+ * @throws {Error} If reading the configuration fails for reasons other than it not existing.
66
+ */
67
+ export async function readConfig(sideId, s3Client, options) {
68
+ const version = options?.version;
69
+ const s3Bucket = options?.s3Bucket || process.env.S3_BUCKET_NAME;
70
+
71
+ const getObjectCommand = new GetObjectCommand({
72
+ Bucket: s3Bucket,
73
+ Key: llmoConfigPath(sideId),
74
+ VersionId: version,
75
+ });
76
+ let res;
77
+ try {
78
+ res = await s3Client.send(getObjectCommand);
79
+ } catch (e) {
80
+ if (e.name === 'NoSuchKey' || e.name === 'NotFound') {
81
+ // Config does not exist yet. Return empty config.
82
+ return { config: defaultConfig(), exists: false };
83
+ }
84
+ throw e;
85
+ }
86
+
87
+ const body = res.Body;
88
+ if (!body) {
89
+ throw new Error('LLMO config body is empty');
90
+ }
91
+ const text = await body.transformToString();
92
+ const config = llmoConfig.parse(JSON.parse(text));
93
+ return { config, exists: true };
94
+ }
95
+
96
+ /**
97
+ * Writes the LLMO configuration for a given site.
98
+ * @param {string} siteId The ID of the site.
99
+ * @param {LLMOConfig} config The configuration object to write.
100
+ * @param {S3Client} s3Client The S3 client to use for reading the configuration.
101
+ * @param {object} [options]
102
+ * @param {string} [options.s3Bucket] Optional S3 bucket name.
103
+ * @returns {Promise<{ version: string }>} The version of the configuration written.
104
+ */
105
+ export async function writeConfig(siteId, config, s3Client, options) {
106
+ const s3Bucket = options?.s3Bucket || process.env.S3_BUCKET_NAME;
107
+
108
+ const putObjectCommand = new PutObjectCommand({
109
+ Bucket: s3Bucket,
110
+ Key: llmoConfigPath(siteId),
111
+ Body: JSON.stringify(config, null, 2),
112
+ ContentType: 'application/json',
113
+ });
114
+ const res = await s3Client.send(putObjectCommand);
115
+ if (!res.VersionId) {
116
+ throw new Error('Failed to get version ID after writing LLMO config');
117
+ }
118
+ return { version: res.VersionId };
119
+ }
@@ -45,7 +45,7 @@ export async function getStoredMetrics(config, context) {
45
45
  const response = await s3.s3Client.send(command);
46
46
  const content = await response.Body?.transformToString();
47
47
  const metrics = JSON.parse(content);
48
- log.info(`Successfully retrieved ${metrics.length} metrics from ${filePath}`);
48
+ log.debug(`Successfully retrieved ${metrics.length} metrics from ${filePath}`);
49
49
 
50
50
  return metrics;
51
51
  } catch (e) {
@@ -72,7 +72,7 @@ export async function storeMetrics(content, config, context) {
72
72
 
73
73
  try {
74
74
  const response = await s3.s3Client.send(command);
75
- log.info(`Successfully uploaded metrics to ${filePath}, response: ${JSON.stringify(response)}`);
75
+ log.debug(`Successfully uploaded metrics to ${filePath}, response: ${JSON.stringify(response)}`);
76
76
 
77
77
  return filePath;
78
78
  } catch (e) {
package/src/schemas.js ADDED
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-disable no-use-before-define */
14
+
15
+ import * as z from 'zod';
16
+
17
+ // ===== SCHEMA DEFINITIONS ====================================================
18
+ // Schemas defined here must be forward- and backward-compatible when making changes.
19
+ // This means:
20
+ // - Always wrap arrays in an object, so that extra properties can be added later.
21
+ // - When using unions, always include a catchall case to cover unknown future cases.
22
+ // - Always allow extra properties in objects, so that future config versions don't break parsing.
23
+ // (this is the default. Do not add `.strict()`!)
24
+ // - it is ok to add new optional properties to objects, but not to remove existing ones.
25
+ // - enums (z.enum([...])) are not forward-compatible.
26
+ // Use z.string() or `z.union([..., z.string()])` instead.
27
+ // - never rename properties, only add new ones.
28
+ // - never broaden or narrow types.
29
+ // E.g., don't change `z.string()` to `z.union([z.string(), z.number()])` or vice versa.
30
+ // If you anticipate that need, use the union from the start.
31
+ // ============================================================================
32
+
33
+ /**
34
+ * @typedef {z.output<llmoConfig>} LLMOConfig
35
+ */
36
+
37
+ const nonEmptyString = z.string().min(1);
38
+
39
+ const entity = z.union([
40
+ z.object({ type: z.literal('category'), name: nonEmptyString }),
41
+ z.object({ type: z.literal('topic'), name: nonEmptyString }),
42
+ z.object({ type: nonEmptyString }),
43
+ ]);
44
+
45
+ const region = z.string().length(2).regex(/^[a-z][a-z]$/i);
46
+
47
+ export const llmoConfig = z.object({
48
+ entities: z.record(z.uuid(), entity),
49
+ brands: z.object({
50
+ aliases: z.array(
51
+ z.object({
52
+ aliases: z.array(nonEmptyString),
53
+ category: z.uuid(),
54
+ region: z.union([region, z.array(region)]),
55
+ topic: z.uuid(),
56
+ }),
57
+ ),
58
+ }),
59
+ competitors: z.object({
60
+ competitors: z.array(
61
+ z.object({
62
+ category: z.uuid(),
63
+ region: z.union([region, z.array(region)]),
64
+ name: nonEmptyString,
65
+ aliases: z.array(nonEmptyString),
66
+ urls: z.array(z.url().optional()),
67
+ }),
68
+ ),
69
+ }),
70
+ }).superRefine((value, ctx) => {
71
+ const { entities, brands, competitors } = value;
72
+
73
+ brands.aliases.forEach((alias, index) => {
74
+ ensureEntityType(entities, ctx, alias.category, 'category', ['brands', 'aliases', index, 'category'], 'category');
75
+ ensureEntityType(entities, ctx, alias.topic, 'topic', ['brands', 'aliases', index, 'topic'], 'topic');
76
+ });
77
+
78
+ competitors.competitors.forEach((competitor, index) => {
79
+ ensureEntityType(entities, ctx, competitor.category, 'category', ['competitors', 'competitors', index, 'category'], 'category');
80
+ });
81
+ });
82
+
83
+ /**
84
+ * @param {LLMOConfig['entities']} entities
85
+ * @param {z.RefinementCtx} ctx
86
+ * @param {string} id
87
+ * @param {string} expectedType
88
+ * @param {Array<number | string>} path
89
+ * @param {string} refLabel
90
+ */
91
+ function ensureEntityType(entities, ctx, id, expectedType, path, refLabel) {
92
+ const entityValue = entities[id];
93
+ if (!entityValue) {
94
+ ctx.addIssue({
95
+ code: 'custom',
96
+ path,
97
+ message: `Unknown ${refLabel} entity: ${id}`,
98
+ });
99
+ return;
100
+ }
101
+
102
+ if (entityValue.type !== expectedType) {
103
+ ctx.addIssue({
104
+ code: 'custom',
105
+ path,
106
+ message: `Entity ${id} referenced as ${refLabel} must have type "${expectedType}" but was "${entityValue.type}"`,
107
+ });
108
+ }
109
+ }
package/src/sqs.js CHANGED
@@ -71,7 +71,7 @@ class SQS {
71
71
 
72
72
  try {
73
73
  const data = await this.sqsClient.send(msgCommand);
74
- this.log.info(`Success, message sent. MessageID: ${data.MessageId}`);
74
+ this.log.debug(`Success, message sent. MessageID: ${data.MessageId}`);
75
75
  } catch (e) {
76
76
  const { type, code, message: msg } = e;
77
77
  this.log.error(`Message sent failed. Type: ${type}, Code: ${code}, Message: ${msg}`);
@@ -120,7 +120,7 @@ export function sqsEventAdapter(fn) {
120
120
 
121
121
  const record = records[0];
122
122
 
123
- log.info(`Received ${records.length} records. ID of the first message in the batch: ${record.messageId}`);
123
+ log.debug(`Received ${records.length} records. ID of the first message in the batch: ${record.messageId}`);
124
124
 
125
125
  try {
126
126
  message = JSON.parse(record.body);