@reactionary/source 0.3.2 → 0.3.3
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/core/src/client/client-builder.ts +8 -0
- package/core/src/client/client.ts +4 -0
- package/core/src/providers/index.ts +2 -0
- package/core/src/providers/product-associations.provider.ts +49 -0
- package/core/src/providers/product-recommendations.provider.ts +150 -0
- package/core/src/schemas/capabilities.schema.ts +2 -0
- package/core/src/schemas/models/identifiers.model.ts +8 -0
- package/core/src/schemas/models/index.ts +1 -0
- package/core/src/schemas/models/product-recommendations.model.ts +10 -0
- package/core/src/schemas/queries/index.ts +2 -0
- package/core/src/schemas/queries/product-associations.query.ts +23 -0
- package/core/src/schemas/queries/product-recommendations.query.ts +72 -0
- package/documentation/docs/7-marketing.md +95 -0
- package/examples/node/package.json +6 -6
- package/examples/node/src/capabilities/product-recommendations.spec.ts +96 -0
- package/examples/node/src/utils.ts +3 -0
- package/package.json +1 -1
- package/providers/algolia/src/core/initialize.ts +6 -0
- package/providers/algolia/src/index.ts +1 -0
- package/providers/algolia/src/providers/index.ts +2 -1
- package/providers/algolia/src/providers/product-recommendations.provider.ts +234 -0
- package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
- package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
- package/providers/medusa/src/core/initialize.ts +6 -0
- package/providers/medusa/src/index.ts +1 -0
- package/providers/medusa/src/providers/product-recommendations.provider.ts +117 -0
- package/providers/medusa/src/schema/capabilities.schema.ts +1 -0
- package/providers/meilisearch/src/core/initialize.ts +6 -0
- package/providers/meilisearch/src/index.ts +1 -0
- package/providers/meilisearch/src/providers/index.ts +1 -0
- package/providers/meilisearch/src/providers/product-recommendations.provider.ts +89 -0
- package/providers/meilisearch/src/schema/capabilities.schema.ts +1 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RequestContextSchema,
|
|
7
7
|
type RequestContext,
|
|
8
8
|
} from '../schemas/session.schema.js';
|
|
9
|
+
import { MulticastProductRecommendationsProvider, type ProductRecommendationsProvider } from '../providers/product-recommendations.provider.js';
|
|
9
10
|
|
|
10
11
|
type CapabilityFactory<T> = (cache: Cache, context: RequestContext) => T;
|
|
11
12
|
|
|
@@ -52,6 +53,8 @@ export class ClientBuilder<TClient = Client> {
|
|
|
52
53
|
|
|
53
54
|
const mergedAnalytics: AnalyticsProvider[] = [];
|
|
54
55
|
|
|
56
|
+
const mergedProductRecommendationsProviders: ProductRecommendationsProvider[] = [];
|
|
57
|
+
|
|
55
58
|
for (const factory of this.factories) {
|
|
56
59
|
const provider = factory(sharedCache, this.context);
|
|
57
60
|
client = {
|
|
@@ -62,12 +65,17 @@ export class ClientBuilder<TClient = Client> {
|
|
|
62
65
|
if (provider.analytics) {
|
|
63
66
|
mergedAnalytics.push(provider.analytics);
|
|
64
67
|
}
|
|
68
|
+
|
|
69
|
+
if (provider.productRecommendations) {
|
|
70
|
+
mergedProductRecommendationsProviders.push(provider.productRecommendations);
|
|
71
|
+
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
// Add cache to complete the client
|
|
68
75
|
const completeClient = {
|
|
69
76
|
...client,
|
|
70
77
|
analytics: new MulticastAnalyticsProvider(sharedCache, this.context, mergedAnalytics),
|
|
78
|
+
productRecommendations: new MulticastProductRecommendationsProvider(sharedCache, this.context, mergedProductRecommendationsProviders),
|
|
71
79
|
cache: sharedCache,
|
|
72
80
|
} as TClient & { cache: Cache };
|
|
73
81
|
|
|
@@ -8,10 +8,14 @@ import type { Cache } from "../cache/cache.interface.js";
|
|
|
8
8
|
import type { CategoryProvider } from "../providers/category.provider.js";
|
|
9
9
|
import type { AnalyticsProvider, CheckoutProvider, OrderProvider, ProfileProvider, StoreProvider } from "../providers/index.js";
|
|
10
10
|
import type { OrderSearchProvider } from "../providers/order-search.provider.js";
|
|
11
|
+
import type { ProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
12
|
+
import type { ProductAssociationsProvider } from "../providers/product-associations.provider.js";
|
|
11
13
|
|
|
12
14
|
export interface Client {
|
|
13
15
|
product: ProductProvider,
|
|
14
16
|
productSearch: ProductSearchProvider,
|
|
17
|
+
productRecommendations: ProductRecommendationsProvider,
|
|
18
|
+
productAssociations: ProductAssociationsProvider,
|
|
15
19
|
identity: IdentityProvider,
|
|
16
20
|
cache: Cache,
|
|
17
21
|
cart: CartProvider,
|
|
@@ -9,6 +9,8 @@ export * from './price.provider.js';
|
|
|
9
9
|
export * from './product.provider.js';
|
|
10
10
|
export * from './profile.provider.js';
|
|
11
11
|
export * from './product-search.provider.js';
|
|
12
|
+
export * from './product-recommendations.provider.js';
|
|
13
|
+
export * from './product-associations.provider.js';
|
|
12
14
|
export * from './store.provider.js';
|
|
13
15
|
export * from './order.provider.js'
|
|
14
16
|
export * from './order-search.provider.js'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ProductIdentifier, ProductVariantIdentifier } from "../schemas/index.js";
|
|
2
|
+
import type { ProductAssociationsGetAccessoriesQuery, ProductAssociationsGetSparepartsQuery, ProductAssociationsGetReplacementsQuery } from "../schemas/queries/product-associations.query.js";
|
|
3
|
+
import { BaseProvider } from "./base.provider.js";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The product association provider is responsible for providing evidence based associations between products, such as
|
|
8
|
+
* accessories, spareparts, and replacements. These associations are typically used to provide recommendations to customers on the product detail page, but can also be used in other contexts such as the cart or post-purchase, but
|
|
9
|
+
* do not carry any personalization concept to them.
|
|
10
|
+
*/
|
|
11
|
+
export abstract class ProductAssociationsProvider extends BaseProvider {
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns a list of product identifiers which are accessories to the given product.
|
|
15
|
+
* Accessories in are products in their own right, but are commonly purchased alongside or recommended as complementary to the main product. Examples of accessories include:
|
|
16
|
+
* - A phone case for a smartphone
|
|
17
|
+
* - A camera bag for a camera
|
|
18
|
+
*
|
|
19
|
+
*
|
|
20
|
+
* Usecase:
|
|
21
|
+
* - PDP: Accessories for this product
|
|
22
|
+
*/
|
|
23
|
+
public abstract getAccessories(query: ProductAssociationsGetAccessoriesQuery): Promise<ProductVariantIdentifier[]>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns a list of product identifiers which are spareparts to the given product.
|
|
27
|
+
* Spareparts are products which are necessary for the use of the main product, but are not typically purchased alongside it. Examples of spareparts include:
|
|
28
|
+
*
|
|
29
|
+
* Usecase:
|
|
30
|
+
* - PDP: Accessories for this product
|
|
31
|
+
*/
|
|
32
|
+
public abstract getSpareparts(query: ProductAssociationsGetSparepartsQuery): Promise<ProductVariantIdentifier[]>;
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This product is replaced by these equivalent or newer products
|
|
37
|
+
* @param query
|
|
38
|
+
*/
|
|
39
|
+
public abstract getReplacements(query: ProductAssociationsGetReplacementsQuery): Promise<ProductVariantIdentifier[]>;
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
getResourceName(): string {
|
|
43
|
+
return 'product-associations';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Cache } from '../cache/cache.interface.js';
|
|
2
|
+
import { Reactionary } from "../decorators/reactionary.decorator.js";
|
|
3
|
+
import { success, type ProductRecommendation, type ProductRecommendationsByCollectionQuery, type RequestContext, type Result } from "../schemas/index.js";
|
|
4
|
+
import { ProductRecommendationsQuerySchema, type ProductRecommendationAlgorithmAlsoViewedProductsQuery, type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery, type ProductRecommendationAlgorithmPopuplarProductsQuery, type ProductRecommendationAlgorithmRelatedProductsQuery, type ProductRecommendationAlgorithmSimilarProductsQuery, type ProductRecommendationAlgorithmTopPicksProductsQuery, type ProductRecommendationAlgorithmTrendingInCategoryQuery, type ProductRecommendationsQuery } from "../schemas/queries/product-recommendations.query.js";
|
|
5
|
+
import { BaseProvider } from "./base.provider.js";
|
|
6
|
+
|
|
7
|
+
export abstract class ProductRecommendationsProvider extends BaseProvider {
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* returns a list of recommended products, based on the selected algorithm and the provided query parameters. The recommendations should be relevant to the product specified in the query, and can be personalized based on the customer segments or contexts provided. The provider should return a list of product variant identifiers that are recommended for the given product, which can then be used to fetch the full product details from the product provider if needed.
|
|
11
|
+
* *
|
|
12
|
+
* Usecase:
|
|
13
|
+
* - PDP - "Customers who viewed this product also viewed"
|
|
14
|
+
* - Cart - "You might also like"
|
|
15
|
+
* - Post-purchase - "Customers who bought this product also bought"
|
|
16
|
+
* - Article page: "Products related to the product mentioned in this article"
|
|
17
|
+
* @param query
|
|
18
|
+
*/
|
|
19
|
+
public async getRecommendations(query: ProductRecommendationsQuery): Promise<Result<ProductRecommendation[]>> {
|
|
20
|
+
if (query.algorithm === 'frequentlyBoughtTogether') {
|
|
21
|
+
return success(await this.getFrequentlyBoughtTogetherRecommendations(query));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (query.algorithm === 'similar') {
|
|
25
|
+
return success(await this.getSimilarProductsRecommendations(query));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (query.algorithm === 'related') {
|
|
29
|
+
return success(await this.getRelatedProductsRecommendations(query));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (query.algorithm === 'trendingInCategory') {
|
|
33
|
+
return success(await this.getTrendingInCategoryRecommendations(query));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (query.algorithm === 'popular') {
|
|
37
|
+
return success(await this.getPopularProductsRecommendations(query));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (query.algorithm === 'topPicks') {
|
|
41
|
+
return success(await this.getTopPicksProductsRecommendations(query));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (query.algorithm === 'alsoViewed') {
|
|
45
|
+
return success(await this.getAlsoViewedProductsRecommendations(query));
|
|
46
|
+
}
|
|
47
|
+
return success([]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public async getCollection(query: ProductRecommendationsByCollectionQuery): Promise<Result<ProductRecommendation[]>> {
|
|
51
|
+
return success([]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
protected async getFrequentlyBoughtTogetherRecommendations(query: ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery): Promise<ProductRecommendation[]> {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected async getSimilarProductsRecommendations(query: ProductRecommendationAlgorithmSimilarProductsQuery): Promise<ProductRecommendation[]> {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
protected async getTrendingInCategoryRecommendations(query: ProductRecommendationAlgorithmTrendingInCategoryQuery): Promise<ProductRecommendation[]> {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protected async getRelatedProductsRecommendations(query: ProductRecommendationAlgorithmRelatedProductsQuery): Promise<ProductRecommendation[]> {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
protected async getPopularProductsRecommendations(query: ProductRecommendationAlgorithmPopuplarProductsQuery): Promise<ProductRecommendation[]> {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected async getTopPicksProductsRecommendations(query: ProductRecommendationAlgorithmTopPicksProductsQuery): Promise<ProductRecommendation[]> {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
protected async getAlsoViewedProductsRecommendations(query: ProductRecommendationAlgorithmAlsoViewedProductsQuery): Promise<ProductRecommendation[]> {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
protected override getResourceName(): string {
|
|
84
|
+
return 'product-recommendations';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
export class MulticastProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
93
|
+
protected providers: Array<ProductRecommendationsProvider>;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
cache: Cache,
|
|
97
|
+
requestContext: RequestContext,
|
|
98
|
+
providers: Array<ProductRecommendationsProvider>
|
|
99
|
+
) {
|
|
100
|
+
super(cache, requestContext);
|
|
101
|
+
|
|
102
|
+
this.providers = providers;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Reactionary({
|
|
106
|
+
inputSchema: ProductRecommendationsQuerySchema,
|
|
107
|
+
})
|
|
108
|
+
public override async getRecommendations(query: ProductRecommendationsQuery): Promise<Result<ProductRecommendation[]>> {
|
|
109
|
+
const output = [];
|
|
110
|
+
for (const provider of this.providers) {
|
|
111
|
+
const providerOutput = await provider.getRecommendations(query);
|
|
112
|
+
if (providerOutput.success) {
|
|
113
|
+
|
|
114
|
+
output.push(...providerOutput.value);
|
|
115
|
+
} else {
|
|
116
|
+
// For other types of errors, we might want to log them or handle them differently
|
|
117
|
+
console.error(`Error from provider ${provider.constructor.name}:`, providerOutput.error);
|
|
118
|
+
return providerOutput
|
|
119
|
+
}
|
|
120
|
+
if (output.length >= query.numberOfRecommendations) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return success(output.slice(0, query.numberOfRecommendations));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public override async getCollection(query: ProductRecommendationsByCollectionQuery): Promise<Result<ProductRecommendation[]>> {
|
|
128
|
+
const output = [];
|
|
129
|
+
for (const provider of this.providers) {
|
|
130
|
+
const providerOutput = await provider.getCollection(query);
|
|
131
|
+
if (providerOutput.success) {
|
|
132
|
+
output.push(...providerOutput.value);
|
|
133
|
+
} else {
|
|
134
|
+
if (providerOutput.error.type === 'NotFound') {
|
|
135
|
+
// If the error is a NotFound error, we can ignore it and continue to the next provider
|
|
136
|
+
continue;
|
|
137
|
+
} else {
|
|
138
|
+
// For other types of errors, we might want to log them or handle them differently
|
|
139
|
+
console.error(`Error from provider ${provider.constructor.name}:`, providerOutput.error);
|
|
140
|
+
return providerOutput;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (output.length >= query.numberOfRecommendations) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return success(output.slice(0, query.numberOfRecommendations));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
}
|
|
@@ -5,6 +5,8 @@ import type { Client } from '../client/client.js';
|
|
|
5
5
|
export const CapabilitiesSchema = z.looseObject({
|
|
6
6
|
product: z.boolean(),
|
|
7
7
|
productSearch: z.boolean(),
|
|
8
|
+
productAssociations: z.boolean(),
|
|
9
|
+
productRecommendations: z.boolean(),
|
|
8
10
|
analytics: z.boolean(),
|
|
9
11
|
identity: z.boolean(),
|
|
10
12
|
cart: z.boolean(),
|
|
@@ -118,6 +118,11 @@ export const PickupPointIdentifierSchema = z.looseObject({
|
|
|
118
118
|
key: z.string(),
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
export const ProductRecommendationIdentifierSchema = z.looseObject({
|
|
122
|
+
key: z.string(),
|
|
123
|
+
algorithm: z.string(),
|
|
124
|
+
});
|
|
125
|
+
|
|
121
126
|
|
|
122
127
|
export const ProductSearchIdentifierSchema = z.looseObject({
|
|
123
128
|
term: z.string().describe('The search term used to find products.'),
|
|
@@ -141,6 +146,7 @@ export const OrderSearchIdentifierSchema = z.looseObject({
|
|
|
141
146
|
paginationOptions: PaginationOptionsSchema.describe('Pagination options for the search results.'),
|
|
142
147
|
});
|
|
143
148
|
|
|
149
|
+
|
|
144
150
|
export type OrderSearchIdentifier = InferType<typeof OrderSearchIdentifierSchema>;
|
|
145
151
|
export type ProductIdentifier = InferType<typeof ProductIdentifierSchema>;
|
|
146
152
|
export type ProductVariantIdentifier = InferType<typeof ProductVariantIdentifierSchema>;
|
|
@@ -178,6 +184,7 @@ export type ProductOptionIdentifier = InferType<typeof ProductOptionIdentifierSc
|
|
|
178
184
|
export type ProductOptionValueIdentifier = InferType<typeof ProductOptionValueIdentifierSchema>;
|
|
179
185
|
export type ProductAttributeIdentifier = InferType<typeof ProductAttributeIdentifierSchema>;
|
|
180
186
|
export type ProductAttributeValueIdentifier = InferType<typeof ProductAttributeValueIdentifierSchema>;
|
|
187
|
+
export type ProductRecommendationIdentifier = InferType<typeof ProductRecommendationIdentifierSchema>;
|
|
181
188
|
|
|
182
189
|
export type IdentifierType =
|
|
183
190
|
| ProductIdentifier
|
|
@@ -191,6 +198,7 @@ export type IdentifierType =
|
|
|
191
198
|
| CategoryIdentifier
|
|
192
199
|
| WebStoreIdentifier
|
|
193
200
|
| InventoryIdentifier
|
|
201
|
+
| ProductRecommendationIdentifier
|
|
194
202
|
| FulfillmentCenterIdentifier
|
|
195
203
|
| IdentityIdentifier
|
|
196
204
|
| ShippingMethodIdentifier
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import { ProductIdentifierSchema, ProductRecommendationIdentifierSchema, ProductVariantIdentifierSchema } from "./identifiers.model.js";
|
|
3
|
+
|
|
4
|
+
export const ProductRecommendationSchema = z.looseObject({
|
|
5
|
+
recommendationIdentifier: ProductRecommendationIdentifierSchema.describe('The identifier for the product recommendation, which includes a key and an algorithm and any other vendor specific/instance specific data '),
|
|
6
|
+
product: ProductIdentifierSchema.describe('The identifier for the recommended product.'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export type ProductRecommendation = z.infer<typeof ProductRecommendationSchema>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import { BaseQuerySchema } from "./base.query.js";
|
|
3
|
+
import { ProductIdentifierSchema, ProductVariantIdentifierSchema } from "../models/identifiers.model.js";
|
|
4
|
+
import { CartItemSchema } from "../models/cart.model.js";
|
|
5
|
+
|
|
6
|
+
export const ProductAssociationsGetAccessoriesQuerySchema = BaseQuerySchema.extend({
|
|
7
|
+
forProductVariant: ProductVariantIdentifierSchema.describe('The product variant identifier for which to get accessory recommendations. The provider should return recommendations that are relevant to this product, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
|
|
8
|
+
numberOfAccessories: z.number().min(1).max(12).describe('The number of accessory recommendations requested. The provider may return fewer than this number, but should not return more.'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const ProductAssociationsGetSparepartsQuerySchema = BaseQuerySchema.extend({
|
|
12
|
+
forProductVariant: ProductVariantIdentifierSchema.describe('The product variant identifier for which to get similar item recommendations. The provider should return recommendations that are relevant to this product, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const ProductAssociationsGetReplacementsQuerySchema = BaseQuerySchema.extend({
|
|
16
|
+
forProductVariant: ProductVariantIdentifierSchema.describe('The product variant identifier for which to get replacement recommendations. The provider should return recommendations that are relevant to this product, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export type ProductAssociationsGetAccessoriesQuery = z.infer<typeof ProductAssociationsGetAccessoriesQuerySchema>;
|
|
22
|
+
export type ProductAssociationsGetSparepartsQuery = z.infer<typeof ProductAssociationsGetSparepartsQuerySchema>;
|
|
23
|
+
export type ProductAssociationsGetReplacementsQuery = z.infer<typeof ProductAssociationsGetReplacementsQuerySchema>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import type { InferType } from "../../zod-utils.js";
|
|
3
|
+
import { CategoryIdentifierSchema, ProductIdentifierSchema } from "../models/identifiers.model.js";
|
|
4
|
+
import { BaseQuerySchema } from "./base.query.js";
|
|
5
|
+
|
|
6
|
+
export const ProductRecommendationBaseQuerySchema = BaseQuerySchema.extend({
|
|
7
|
+
numberOfRecommendations: z.number().min(1).max(12).describe('The number of recommendations requested. The provider may return fewer than this number, but should not return more.'),
|
|
8
|
+
labels: z.array(z.string()).optional().describe('The customer segments, quirks, chirps or other labels to which the recommendations can optimize themselves to be relevant. This can be used by the provider to personalize the recommendations based on the preferences and behaviors of users in these segments.'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const ProductRecommendationsByCollectionQuerySchema = ProductRecommendationBaseQuerySchema.extend({
|
|
12
|
+
collectionName: z.string().describe('The name of the collection for which to get product recommendations. This is to access either manually curated lists, or interface marketing rules engines that define zones by name'),
|
|
13
|
+
sourceProduct: z.array(ProductIdentifierSchema).optional().describe('The products on screen or in the context you are asking for the recommendations. Could be all the variants from the current cart (resolved into their products), or just the variant of the PDP, or the 4 first products of a category.'),
|
|
14
|
+
sourceCategory: CategoryIdentifierSchema.optional().describe('The category identifier to use as a seed for the recommendations. The provider should return recommendations that are relevant to this category, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences. This is optional, as the collection may already be curated to be relevant to a specific product or category, but it can be used by the provider to further personalize the recommendations based on the preferences and behaviors of users who have interacted with this category.'),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const ProductRecommendationProductBasedBaseQuerySchema = ProductRecommendationBaseQuerySchema.extend({
|
|
18
|
+
sourceProduct: ProductIdentifierSchema.describe('The product identifiers for which to get recommendations. The provider should return recommendations that are relevant to these products, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
|
|
22
|
+
algorithm: z.literal('frequentlyBoughtTogether').describe('The provider should return recommendations based on products that are frequently bought together with the source products. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.'),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const ProductRecommendationAlgorithmSimilarProductsQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
|
|
26
|
+
algorithm: z.literal('similar').describe('The provider should return recommendations based on products that are similar to the source products either visually or data wise'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const ProductRecommendationAlgorithmRelatedProductsQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
|
|
30
|
+
algorithm: z.literal('related').describe('The provider should return recommendations based on products that are related to the source products. '),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const ProductRecommendationAlgorithmTrendingInCategoryQuerySchema = ProductRecommendationBaseQuerySchema.extend({
|
|
34
|
+
algorithm: z.literal('trendingInCategory').describe('The provider should return recommendations based on products that are trending in the specified category. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.'),
|
|
35
|
+
sourceCategory: CategoryIdentifierSchema.describe('The category identifier for which to get trending product recommendations. The provider should return recommendations that are relevant to this category, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
// unsure if we need both Popular and TopPicks, as they are quite similar. Maybe we can merge them into one algorithm with a parameter to specify the type of popularity? For now, I'll keep them separate for clarity.
|
|
40
|
+
export const ProductRecommendationAlgorithmPopuplarProductsQuerySchema = ProductRecommendationBaseQuerySchema.extend({
|
|
41
|
+
algorithm: z.literal('popular').describe('The provider should return recommendations based on products that are popular among users. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.'),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const ProductRecommendationAlgorithmTopPicksProductsQuerySchema = ProductRecommendationBaseQuerySchema.extend({
|
|
45
|
+
algorithm: z.literal('topPicks').describe('The provider should return recommendations based on products that are top picks among users. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.') ,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const ProductRecommendationAlgorithmAlsoViewedProductsQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
|
|
49
|
+
algorithm: z.literal('alsoViewed').describe('The provider should return recommendations based on products that are also viewed by users. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.') ,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
export const ProductRecommendationsQuerySchema = z.discriminatedUnion('algorithm', [
|
|
55
|
+
ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuerySchema,
|
|
56
|
+
ProductRecommendationAlgorithmTrendingInCategoryQuerySchema,
|
|
57
|
+
ProductRecommendationAlgorithmSimilarProductsQuerySchema,
|
|
58
|
+
ProductRecommendationAlgorithmRelatedProductsQuerySchema,
|
|
59
|
+
ProductRecommendationAlgorithmPopuplarProductsQuerySchema,
|
|
60
|
+
ProductRecommendationAlgorithmTopPicksProductsQuerySchema,
|
|
61
|
+
ProductRecommendationAlgorithmAlsoViewedProductsQuerySchema
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export type ProductRecommendationsQuery = InferType<typeof ProductRecommendationsQuerySchema>;
|
|
65
|
+
export type ProductRecommendationsByCollectionQuery = InferType<typeof ProductRecommendationsByCollectionQuerySchema>;
|
|
66
|
+
export type ProductRecommendationAlgorithmTopPicksProductsQuery = InferType<typeof ProductRecommendationAlgorithmTopPicksProductsQuerySchema>;
|
|
67
|
+
export type ProductRecommendationAlgorithmPopuplarProductsQuery = InferType<typeof ProductRecommendationAlgorithmPopuplarProductsQuerySchema>;
|
|
68
|
+
export type ProductRecommendationAlgorithmTrendingInCategoryQuery = InferType<typeof ProductRecommendationAlgorithmTrendingInCategoryQuerySchema>;
|
|
69
|
+
export type ProductRecommendationAlgorithmRelatedProductsQuery = InferType<typeof ProductRecommendationAlgorithmRelatedProductsQuerySchema>;
|
|
70
|
+
export type ProductRecommendationAlgorithmSimilarProductsQuery= InferType<typeof ProductRecommendationAlgorithmSimilarProductsQuerySchema>;
|
|
71
|
+
export type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery = InferType<typeof ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuerySchema>;
|
|
72
|
+
export type ProductRecommendationAlgorithmAlsoViewedProductsQuery = InferType<typeof ProductRecommendationAlgorithmAlsoViewedProductsQuerySchema>;
|
|
@@ -1,3 +1,98 @@
|
|
|
1
1
|
# Adding the personal touch
|
|
2
2
|
|
|
3
|
+
Reactionary supports many mechanisms by which to make the customer journey unique to a customer. The main mechanism is via the `ProductRecommendationsProvider` which offers 7 unique ways to find some relevant products to display for the user.
|
|
4
|
+
|
|
5
|
+
It is assumed, that any basic-direct-assign logic (ie editor has picked 4 products to show) is already handled at the CMS level. The Reactionary provider is for the usecase where you want a dynamic result back, based on the users behavior, tags, or whatever you have.
|
|
6
|
+
|
|
7
|
+
The supported algorithms are
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
- Frequently-Bought-Together (Product based)
|
|
11
|
+
- Trending-In-Category (Category based)
|
|
12
|
+
- Similar (Product)
|
|
13
|
+
- Related-Items (Product)
|
|
14
|
+
- Popular-Products (User)
|
|
15
|
+
- Top-Picks-For-You (User)
|
|
16
|
+
- Also-Viewed (Product, User)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Reactionary supports having multiple providers of product recommendations, each of which will be asked in order, until the result is fully populated.
|
|
20
|
+
Every provider might not support every algorithm though.
|
|
21
|
+
|
|
22
|
+
But, the point of this is, that you can either hardcode something on the PDP
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const recommendations = client.productRecommendations.getRecommendations({
|
|
26
|
+
algorithm: 'similar',
|
|
27
|
+
numberOfRecommendations: 8,
|
|
28
|
+
labels: [
|
|
29
|
+
(
|
|
30
|
+
isLoggedIn: 'Registered' : 'Guest',
|
|
31
|
+
registeredThisSession? 'FirstSession': 'ReturningCustomer',
|
|
32
|
+
customerSegments.map(x => x.name),
|
|
33
|
+
new Date().getHour() > 12? "Afternoon": "Morning",
|
|
34
|
+
getTemperatureAround(context.clientIp) < 20: 'Cold': 'Warm'
|
|
35
|
+
)
|
|
36
|
+
]
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (recommendations.success) {
|
|
40
|
+
// we can now resolve them
|
|
41
|
+
const allProducts = Promise.all(
|
|
42
|
+
recommendations.value.products.map(x => client.product.getByID(x)).map(x => x.successs? x.value : null )).filter(y => !!y);
|
|
43
|
+
// and draw them...
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Instead of using the full `client.product` you could have resolved it via the `.productSearch` to get a smaller data-footprint. However, since you are hopefully just about to navigate to one of these pages, it might be better for overall cache performance to use the full data model.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## Collections
|
|
53
|
+
|
|
54
|
+
Some systems offer named access to a potentially rules based selection of products. An example is the HCL Commerce espot system, where your frontpage migth have a spot called "Newest arrivals", and behind the scenes some rules engine decides what to show there.
|
|
55
|
+
|
|
56
|
+
It can also be simply the name of a CMS entity, or other system specific functionality.
|
|
57
|
+
|
|
58
|
+
You can call that as well here:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const recommendations = client.productRecommendations.getCollection({
|
|
62
|
+
collectionName: 'newest-arrivals',
|
|
63
|
+
numberOfRecommendations: 8,
|
|
64
|
+
labels: [
|
|
65
|
+
(
|
|
66
|
+
isLoggedIn: 'Registered' : 'Guest',
|
|
67
|
+
registeredThisSession? 'FirstSession': 'ReturningCustomer',
|
|
68
|
+
customerSegments.map(x => x.name),
|
|
69
|
+
new Date().getHour() > 12? "Afternoon": "Morning",
|
|
70
|
+
getTemperatureAround(context.clientIp) < 20: 'Cold': 'Warm'
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (recommendations.success) {
|
|
76
|
+
// we can now resolve them
|
|
77
|
+
const allProducts = Promise.all(
|
|
78
|
+
recommendations.value.products.map(x => client.product.getByID(x)).map(x => x.successs? x.value : null )).filter(y => !!y);
|
|
79
|
+
// and draw them...
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Configuration
|
|
84
|
+
Since not every system can provide every recommendation algorithm, Reactionary supports enabling this capability on multiple providers.
|
|
85
|
+
|
|
86
|
+
So if you have both algolia and medusa, you can set `productRecommendations: true` on both, and the system will then ask each system in turn until the required number of results have been gathered.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Design decisions
|
|
91
|
+
Since not all systems can deliver full product data back, we are initially only returning the IDs of the products, so you have to use the `product` provider to resolve it into something displayable.
|
|
92
|
+
|
|
93
|
+
Later, we might add a discriminator, allowing the same bare-bones/tile-ready data to be returned from this, as is returned from the product search.
|
|
94
|
+
|
|
95
|
+
It was also decided to have the source and target unit be a product (Forest Ranger T-Shirt), and not the individual sku (Forest Ranger T-Shirt, Yellow, Size-32).
|
|
96
|
+
|
|
97
|
+
This is from emperical studies showign that you will get more results from the toplevel product, than from a rarely used SKU.
|
|
3
98
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/examples-node",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"types": "src/index.d.ts",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@reactionary/core": "0.3.
|
|
8
|
-
"@reactionary/provider-commercetools": "0.3.
|
|
9
|
-
"@reactionary/provider-algolia": "0.3.
|
|
10
|
-
"@reactionary/provider-medusa": "0.3.
|
|
11
|
-
"@reactionary/provider-meilisearch": "0.3.
|
|
7
|
+
"@reactionary/core": "0.3.3",
|
|
8
|
+
"@reactionary/provider-commercetools": "0.3.3",
|
|
9
|
+
"@reactionary/provider-algolia": "0.3.3",
|
|
10
|
+
"@reactionary/provider-medusa": "0.3.3",
|
|
11
|
+
"@reactionary/provider-meilisearch": "0.3.3"
|
|
12
12
|
},
|
|
13
13
|
"type": "module"
|
|
14
14
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createClient, PrimaryProvider } from '../utils.js';
|
|
4
|
+
import type { ProductSearchQueryCreateNavigationFilter } from '@reactionary/core';
|
|
5
|
+
|
|
6
|
+
const testData = {
|
|
7
|
+
product: {
|
|
8
|
+
id: 'product_10959528',
|
|
9
|
+
name: 'Manhattan 170703 cable accessory Cable kit',
|
|
10
|
+
image: 'https://images.icecat.biz/img/norm/high/10959528-2837.jpg',
|
|
11
|
+
sku: '0766623170703',
|
|
12
|
+
slug: 'manhattan-170703-cable-accessory-cable-kit-10959528',
|
|
13
|
+
},
|
|
14
|
+
productWithMultiVariants: {
|
|
15
|
+
slug: 'hp-gk859aa-mouse-office-bluetooth-laser-1600-dpi-1377612',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe.each([PrimaryProvider.MEDUSA])(
|
|
20
|
+
'Product Recommendations - Collections - %s',
|
|
21
|
+
(provider) => {
|
|
22
|
+
let client: ReturnType<typeof createClient>;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
client = createClient(provider);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should be able to return a list of products for a collection', async () => {
|
|
29
|
+
const result = await client.productRecommendations.getCollection({
|
|
30
|
+
collectionName: 'newest-arrivals',
|
|
31
|
+
numberOfRecommendations: 10,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!result.success) {
|
|
35
|
+
assert.fail(JSON.stringify(result.error));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect(result.value.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return an empty result for an unknown collection', async () => {
|
|
42
|
+
const result = await client.productRecommendations.getCollection({
|
|
43
|
+
collectionName: 'Unknown Collection',
|
|
44
|
+
numberOfRecommendations: 10,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
assert.fail(JSON.stringify(result.error));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(result.value.length).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
describe.each([PrimaryProvider.MEILISEARCH])(
|
|
57
|
+
'Product Recommendations - Similar - %s',
|
|
58
|
+
(provider) => {
|
|
59
|
+
let client: ReturnType<typeof createClient>;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
client = createClient(provider);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should be able to return a list of products for recommendation - Similar ', async () => {
|
|
66
|
+
const result = await client.productRecommendations.getRecommendations({
|
|
67
|
+
algorithm: 'similar',
|
|
68
|
+
sourceProduct: {
|
|
69
|
+
key: testData.product.id,
|
|
70
|
+
},
|
|
71
|
+
numberOfRecommendations: 10,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!result.success) {
|
|
75
|
+
assert.fail(JSON.stringify(result.error));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(result.value.length).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return an empty result for an unknown sku', async () => {
|
|
82
|
+
const result = await client.productRecommendations.getRecommendations({
|
|
83
|
+
algorithm: 'similar',
|
|
84
|
+
sourceProduct: {
|
|
85
|
+
key: 'unknown-product-id',
|
|
86
|
+
},
|
|
87
|
+
numberOfRecommendations: 10,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
assert.fail(JSON.stringify(result.error));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(result.value.length).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -91,6 +91,7 @@ export function createClient(provider: PrimaryProvider) {
|
|
|
91
91
|
order: true,
|
|
92
92
|
price: true,
|
|
93
93
|
productSearch: true,
|
|
94
|
+
productRecommendations: true,
|
|
94
95
|
orderSearch: true,
|
|
95
96
|
store: true,
|
|
96
97
|
profile: true
|
|
@@ -124,6 +125,7 @@ export function createClient(provider: PrimaryProvider) {
|
|
|
124
125
|
builder = builder.withCapability(
|
|
125
126
|
withAlgoliaCapabilities(getAlgoliaTestConfiguration(), {
|
|
126
127
|
productSearch: true,
|
|
128
|
+
productRecommendations: true,
|
|
127
129
|
})
|
|
128
130
|
);
|
|
129
131
|
}
|
|
@@ -133,6 +135,7 @@ export function createClient(provider: PrimaryProvider) {
|
|
|
133
135
|
withMeilisearchCapabilities(getMeilisearchTestConfiguration(), {
|
|
134
136
|
productSearch: true,
|
|
135
137
|
orderSearch: true,
|
|
138
|
+
productRecommendations: true,
|
|
136
139
|
}),
|
|
137
140
|
);
|
|
138
141
|
builder = builder.withCapability(
|
package/package.json
CHANGED
|
@@ -3,9 +3,11 @@ import { AlgoliaProductSearchProvider } from "../providers/product-search.provid
|
|
|
3
3
|
import type { AlgoliaCapabilities } from "../schema/capabilities.schema.js";
|
|
4
4
|
import type { AlgoliaConfiguration } from "../schema/configuration.schema.js";
|
|
5
5
|
import { AlgoliaAnalyticsProvider } from "../providers/analytics.provider.js";
|
|
6
|
+
import { AlgoliaProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
6
7
|
|
|
7
8
|
export function withAlgoliaCapabilities<T extends AlgoliaCapabilities>(configuration: AlgoliaConfiguration, capabilities: T) {
|
|
8
9
|
return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
11
|
const client: any = {};
|
|
10
12
|
|
|
11
13
|
if (capabilities.productSearch) {
|
|
@@ -16,6 +18,10 @@ export function withAlgoliaCapabilities<T extends AlgoliaCapabilities>(configura
|
|
|
16
18
|
client.analytics = new AlgoliaAnalyticsProvider(cache, context, configuration);
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
if (capabilities.productRecommendations) {
|
|
22
|
+
client.productRecommendations = new AlgoliaProductRecommendationsProvider(configuration, cache, context);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
return client;
|
|
20
26
|
};
|
|
21
27
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Cache,
|
|
3
|
+
ProductRecommendationsProvider,
|
|
4
|
+
type ProductRecommendation,
|
|
5
|
+
type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery,
|
|
6
|
+
type ProductRecommendationAlgorithmSimilarProductsQuery,
|
|
7
|
+
type ProductRecommendationAlgorithmRelatedProductsQuery,
|
|
8
|
+
type ProductRecommendationAlgorithmTrendingInCategoryQuery,
|
|
9
|
+
type RequestContext,
|
|
10
|
+
type ProductRecommendationsQuery,
|
|
11
|
+
} from '@reactionary/core';
|
|
12
|
+
import { recommendClient, type BoughtTogetherQuery, type LookingSimilarQuery, type RecommendationsResults, type RecommendClient, type RecommendSearchParams, type RelatedQuery, type TrendingItemsQuery } from 'algoliasearch';
|
|
13
|
+
import type { AlgoliaConfiguration } from '../schema/configuration.schema.js';
|
|
14
|
+
import type { AlgoliaProductRecommendationIdentifier } from '../schema/product-recommendation.schema.js';
|
|
15
|
+
|
|
16
|
+
interface AlgoliaRecommendHit {
|
|
17
|
+
objectID: string;
|
|
18
|
+
sku?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AlgoliaProductRecommendationsProvider
|
|
24
|
+
*
|
|
25
|
+
* Provides product recommendations using Algolia's Recommend API.
|
|
26
|
+
* Supports frequentlyBoughtTogether, similar, related, and trendingInCategory algorithms.
|
|
27
|
+
*
|
|
28
|
+
* Note: This requires Algolia Recommend to be enabled and AI models to be trained.
|
|
29
|
+
* See: https://www.algolia.com/doc/guides/algolia-recommend/overview/
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export class AlgoliaProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
36
|
+
protected config: AlgoliaConfiguration;
|
|
37
|
+
|
|
38
|
+
constructor(config: AlgoliaConfiguration, cache: Cache, context: RequestContext) {
|
|
39
|
+
super(cache, context);
|
|
40
|
+
this.config = config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
protected getRecommendClient(): RecommendClient {
|
|
44
|
+
return recommendClient(this.config.appId, this.config.apiKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
48
|
+
protected getRecommendationThreshold(_algorithm: string): number {
|
|
49
|
+
// Default threshold can be customized per algorithm if needed
|
|
50
|
+
// The parameter is currently unused but kept for future algorithm-specific threshold customization
|
|
51
|
+
return 10;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
protected getQueryParametersForRecommendations(algorithm: string): RecommendSearchParams {
|
|
55
|
+
return {
|
|
56
|
+
userToken: this.context.session.identityContext?.personalizationKey || 'anonymous',
|
|
57
|
+
analytics: true,
|
|
58
|
+
analyticsTags: ['reactionary', algorithm],
|
|
59
|
+
clickAnalytics: true
|
|
60
|
+
} satisfies RecommendSearchParams;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get frequently bought together recommendations using Algolia Recommend
|
|
65
|
+
*/
|
|
66
|
+
protected override async getFrequentlyBoughtTogetherRecommendations(
|
|
67
|
+
query: ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery
|
|
68
|
+
): Promise<ProductRecommendation[]> {
|
|
69
|
+
const client = this.getRecommendClient();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Note: Algolia's Recommend API requires setting up AI Recommend models
|
|
73
|
+
// This implementation uses the getRecommendations method from the recommend client
|
|
74
|
+
const response = await client.getRecommendations({
|
|
75
|
+
requests: [
|
|
76
|
+
{
|
|
77
|
+
indexName: this.config.indexName,
|
|
78
|
+
model: 'bought-together',
|
|
79
|
+
objectID: query.sourceProduct.key,
|
|
80
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
81
|
+
threshold: this.getRecommendationThreshold('bought-together'),
|
|
82
|
+
queryParameters: this.getQueryParametersForRecommendations('bought-together')
|
|
83
|
+
} satisfies BoughtTogetherQuery,
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = [];
|
|
89
|
+
if (response.results) {
|
|
90
|
+
for(const res of response.results) {
|
|
91
|
+
result.push(...this.parseRecommendation(res, query));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('Error fetching frequently bought together recommendations:', error);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get similar product recommendations using Algolia Recommend
|
|
103
|
+
*/
|
|
104
|
+
protected override async getSimilarProductsRecommendations(
|
|
105
|
+
query: ProductRecommendationAlgorithmSimilarProductsQuery
|
|
106
|
+
): Promise<ProductRecommendation[]> {
|
|
107
|
+
const client = this.getRecommendClient();
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const response = await client.getRecommendations({
|
|
111
|
+
requests: [
|
|
112
|
+
{
|
|
113
|
+
indexName: this.config.indexName,
|
|
114
|
+
model: 'looking-similar',
|
|
115
|
+
objectID: query.sourceProduct.key,
|
|
116
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
117
|
+
threshold: this.getRecommendationThreshold('looking-similar'),
|
|
118
|
+
queryParameters: this.getQueryParametersForRecommendations('looking-similar')
|
|
119
|
+
} satisfies LookingSimilarQuery
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = [];
|
|
124
|
+
if (response.results) {
|
|
125
|
+
for(const res of response.results) {
|
|
126
|
+
result.push(...this.parseRecommendation(res, query));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Error fetching similar product recommendations:', error);
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get related product recommendations using Algolia Recommend
|
|
138
|
+
*/
|
|
139
|
+
protected override async getRelatedProductsRecommendations(
|
|
140
|
+
query: ProductRecommendationAlgorithmRelatedProductsQuery
|
|
141
|
+
): Promise<ProductRecommendation[]> {
|
|
142
|
+
const client = this.getRecommendClient();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const response = await client.getRecommendations({
|
|
146
|
+
requests: [
|
|
147
|
+
{
|
|
148
|
+
indexName: this.config.indexName,
|
|
149
|
+
model: 'related-products',
|
|
150
|
+
objectID: query.sourceProduct.key,
|
|
151
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
152
|
+
threshold: this.getRecommendationThreshold('related-products'),
|
|
153
|
+
queryParameters: this.getQueryParametersForRecommendations('related-products')
|
|
154
|
+
} satisfies RelatedQuery,
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = [];
|
|
159
|
+
if (response.results) {
|
|
160
|
+
for(const res of response.results) {
|
|
161
|
+
result.push(...this.parseRecommendation(res, query));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error('Error fetching related product recommendations:', error);
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get trending in category recommendations using Algolia Recommend
|
|
173
|
+
*/
|
|
174
|
+
protected override async getTrendingInCategoryRecommendations(
|
|
175
|
+
query: ProductRecommendationAlgorithmTrendingInCategoryQuery
|
|
176
|
+
): Promise<ProductRecommendation[]> {
|
|
177
|
+
const client = this.getRecommendClient();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const response = await client.getRecommendations({
|
|
181
|
+
requests: [
|
|
182
|
+
{
|
|
183
|
+
indexName: this.config.indexName,
|
|
184
|
+
model: 'trending-items',
|
|
185
|
+
facetName: 'categories',
|
|
186
|
+
facetValue: query.sourceCategory.key,
|
|
187
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
188
|
+
threshold: this.getRecommendationThreshold('trending-items'),
|
|
189
|
+
queryParameters: this.getQueryParametersForRecommendations('trending-items')
|
|
190
|
+
} satisfies TrendingItemsQuery,
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = [];
|
|
195
|
+
if (response.results) {
|
|
196
|
+
for(const res of response.results) {
|
|
197
|
+
result.push(...this.parseRecommendation(res, query));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error('Error fetching trending in category recommendations:', error);
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
protected parseRecommendation(res: RecommendationsResults, query: ProductRecommendationsQuery) {
|
|
209
|
+
const result = [];
|
|
210
|
+
for(const hit of res.hits as AlgoliaRecommendHit[]) {
|
|
211
|
+
const recommendationIdentifier = {
|
|
212
|
+
key: res.queryID || 'x',
|
|
213
|
+
algorithm: query.algorithm,
|
|
214
|
+
abTestID: res.abTestID,
|
|
215
|
+
abTestVariantID: res.abTestVariantID
|
|
216
|
+
} satisfies AlgoliaProductRecommendationIdentifier
|
|
217
|
+
const recommendation = this.parseSingle(hit, recommendationIdentifier)
|
|
218
|
+
result.push(recommendation);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Maps Algolia recommendation results to ProductRecommendation format
|
|
225
|
+
*/
|
|
226
|
+
protected parseSingle(hit: AlgoliaRecommendHit, recommendationIdentifier: AlgoliaProductRecommendationIdentifier): ProductRecommendation {
|
|
227
|
+
return {
|
|
228
|
+
recommendationIdentifier,
|
|
229
|
+
product: {
|
|
230
|
+
key: hit.objectID,
|
|
231
|
+
},
|
|
232
|
+
} satisfies ProductRecommendation;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -3,7 +3,8 @@ import type { z } from 'zod';
|
|
|
3
3
|
|
|
4
4
|
export const AlgoliaCapabilitiesSchema = CapabilitiesSchema.pick({
|
|
5
5
|
productSearch: true,
|
|
6
|
-
analytics: true
|
|
6
|
+
analytics: true,
|
|
7
|
+
productRecommendations: true
|
|
7
8
|
}).partial();
|
|
8
9
|
|
|
9
10
|
export type AlgoliaCapabilities = z.infer<typeof AlgoliaCapabilitiesSchema>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ProductRecommendationIdentifierSchema } from "@reactionary/core";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
export const AlgoliaProductSearchIdentifierSchema = ProductRecommendationIdentifierSchema.extend({
|
|
5
|
+
abTestID: z.number().optional(),
|
|
6
|
+
abTestVariantID: z.number().optional()
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type AlgoliaProductRecommendationIdentifier = z.infer<typeof AlgoliaProductSearchIdentifierSchema>;
|
|
@@ -12,6 +12,7 @@ import { MedusaOrderSearchProvider } from "../providers/order-search.provider.js
|
|
|
12
12
|
import { MedusaOrderProvider } from "../providers/order.provider.js";
|
|
13
13
|
import { MedusaPriceProvider } from "../providers/price.provider.js";
|
|
14
14
|
import { MedusaSearchProvider } from "../providers/product-search.provider.js";
|
|
15
|
+
import { MedusaProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
15
16
|
import { MedusaProductProvider } from "../providers/product.provider.js";
|
|
16
17
|
import { MedusaProfileProvider } from "../providers/profile.provider.js";
|
|
17
18
|
import { MedusaCapabilitiesSchema, type MedusaCapabilities } from "../schema/capabilities.schema.js";
|
|
@@ -23,6 +24,7 @@ export function withMedusaCapabilities<T extends MedusaCapabilities>(
|
|
|
23
24
|
capabilities: T
|
|
24
25
|
) {
|
|
25
26
|
return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
28
|
const client: any = {};
|
|
27
29
|
const config = MedusaConfigurationSchema.parse(configuration);
|
|
28
30
|
const caps = MedusaCapabilitiesSchema.parse(capabilities);
|
|
@@ -34,6 +36,10 @@ export function withMedusaCapabilities<T extends MedusaCapabilities>(
|
|
|
34
36
|
client.productSearch = new MedusaSearchProvider(configuration, cache, context, medusaApi);
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
if (caps.productRecommendations) {
|
|
40
|
+
client.productRecommendations = new MedusaProductRecommendationsProvider(configuration, cache, context, medusaApi);
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
if (caps.category) {
|
|
38
44
|
client.category = new MedusaCategoryProvider(configuration, cache, context, medusaApi);
|
|
39
45
|
}
|
|
@@ -10,4 +10,5 @@ export * from './providers/order.provider.js';
|
|
|
10
10
|
export * from './providers/order-search.provider.js';
|
|
11
11
|
export * from './providers/price.provider.js';
|
|
12
12
|
export * from './providers/product-search.provider.js';
|
|
13
|
+
export * from './providers/product-recommendations.provider.js';
|
|
13
14
|
export * from './providers/profile.provider.js';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Cache,
|
|
3
|
+
ProductRecommendationsProvider,
|
|
4
|
+
type ProductRecommendation,
|
|
5
|
+
type ProductRecommendationsByCollectionQuery,
|
|
6
|
+
type RequestContext,
|
|
7
|
+
type Result,
|
|
8
|
+
success, error,
|
|
9
|
+
NotFoundErrorSchema,
|
|
10
|
+
type NotFoundError,
|
|
11
|
+
errorToString,
|
|
12
|
+
} from '@reactionary/core';
|
|
13
|
+
import createDebug from 'debug';
|
|
14
|
+
import type { MedusaAPI } from '../core/client.js';
|
|
15
|
+
import type { MedusaConfiguration } from '../schema/configuration.schema.js';
|
|
16
|
+
|
|
17
|
+
const debug = createDebug('reactionary:medusa:product-recommendations');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MedusaProductRecommendationsProvider
|
|
21
|
+
*
|
|
22
|
+
* Provides product recommendations using Medusa's collection-based product grouping.
|
|
23
|
+
* Only overrides the getCollection method to fetch products from Medusa collections.
|
|
24
|
+
*
|
|
25
|
+
* Note: This implementation leverages Medusa's product collections feature to provide
|
|
26
|
+
* curated product recommendations. Collections are typically manually managed or
|
|
27
|
+
* created through Medusa's admin panel or API.
|
|
28
|
+
*/
|
|
29
|
+
export class MedusaProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
30
|
+
protected config: MedusaConfiguration;
|
|
31
|
+
protected medusaApi: MedusaAPI;
|
|
32
|
+
|
|
33
|
+
constructor(config: MedusaConfiguration, cache: Cache, context: RequestContext, medusaApi: MedusaAPI) {
|
|
34
|
+
super(cache, context);
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.medusaApi = medusaApi;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get product recommendations from a Medusa collection
|
|
41
|
+
*
|
|
42
|
+
* This method fetches products from a specific Medusa collection by name or handle.
|
|
43
|
+
* It's useful for displaying curated product lists like "Featured Products",
|
|
44
|
+
* "New Arrivals", "Best Sellers", etc.
|
|
45
|
+
*/
|
|
46
|
+
public override async getCollection(
|
|
47
|
+
query: ProductRecommendationsByCollectionQuery
|
|
48
|
+
): Promise<Result<ProductRecommendation[]>> {
|
|
49
|
+
const client = await this.medusaApi.getClient();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (debug.enabled) {
|
|
53
|
+
debug(`Fetching collection: ${query.collectionName}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// First, find the collection by handle/name
|
|
57
|
+
const collectionsResponse = await client.store.collection.list({
|
|
58
|
+
handle: query.collectionName,
|
|
59
|
+
limit: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!collectionsResponse.collections || collectionsResponse.collections.length === 0) {
|
|
63
|
+
if (debug.enabled) {
|
|
64
|
+
debug(`Collection not found: ${query.collectionName}`);
|
|
65
|
+
}
|
|
66
|
+
return error<NotFoundError>({
|
|
67
|
+
type: 'NotFound',
|
|
68
|
+
identifier: query.collectionName
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collection = collectionsResponse.collections[0];
|
|
73
|
+
|
|
74
|
+
if (debug.enabled) {
|
|
75
|
+
debug(`Found collection: ${collection.title} (${collection.id})`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fetch products from the collection
|
|
79
|
+
const productsResponse = await client.store.product.list({
|
|
80
|
+
collection_id: [collection.id],
|
|
81
|
+
limit: query.numberOfRecommendations,
|
|
82
|
+
fields: '+variants.id,+variants.sku',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (debug.enabled) {
|
|
86
|
+
debug(`Found ${productsResponse.products.length} products in collection`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map products to recommendations
|
|
90
|
+
const recommendations: ProductRecommendation[] = [];
|
|
91
|
+
|
|
92
|
+
for (const product of productsResponse.products) {
|
|
93
|
+
recommendations.push({
|
|
94
|
+
recommendationIdentifier: {
|
|
95
|
+
key: `${collection.id}_${product.id}`,
|
|
96
|
+
algorithm: 'collection',
|
|
97
|
+
},
|
|
98
|
+
product: {
|
|
99
|
+
key: product.id
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (debug.enabled) {
|
|
105
|
+
debug(`Returning ${recommendations.length} recommendations`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return success(recommendations);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (debug.enabled) {
|
|
111
|
+
debug(`Error fetching collection recommendations: %O`, error);
|
|
112
|
+
}
|
|
113
|
+
console.error('Error fetching collection recommendations:', error);
|
|
114
|
+
return success([]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { Cache, ClientFromCapabilities, RequestContext } from "@reactionary/core";
|
|
2
2
|
import { MeilisearchSearchProvider } from "../providers/product-search.provider.js";
|
|
3
|
+
import { MeilisearchProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
3
4
|
import { MeilisearchOrderSearchProvider } from "../providers/order-search.provider.js";
|
|
4
5
|
import type { MeilisearchCapabilities } from "../schema/capabilities.schema.js";
|
|
5
6
|
import type { MeilisearchConfiguration } from "../schema/configuration.schema.js";
|
|
6
7
|
|
|
7
8
|
export function withMeilisearchCapabilities<T extends MeilisearchCapabilities>(configuration: MeilisearchConfiguration, capabilities: T) {
|
|
8
9
|
return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
11
|
const client: any = {};
|
|
10
12
|
|
|
11
13
|
if (capabilities.productSearch) {
|
|
12
14
|
client.productSearch = new MeilisearchSearchProvider(configuration, cache, context);
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
if (capabilities.productRecommendations) {
|
|
18
|
+
client.productRecommendations = new MeilisearchProductRecommendationsProvider(configuration, cache, context);
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
if (capabilities.orderSearch) {
|
|
16
22
|
client.orderSearch = new MeilisearchOrderSearchProvider(configuration, cache, context);
|
|
17
23
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './core/initialize.js';
|
|
2
2
|
export * from './providers/product-search.provider.js';
|
|
3
|
+
export * from './providers/product-recommendations.provider.js';
|
|
3
4
|
export * from './providers/order-search.provider.js';
|
|
4
5
|
|
|
5
6
|
export * from './schema/configuration.schema.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Cache,
|
|
3
|
+
ProductRecommendationsProvider,
|
|
4
|
+
type ProductRecommendation,
|
|
5
|
+
type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery,
|
|
6
|
+
type ProductRecommendationAlgorithmSimilarProductsQuery,
|
|
7
|
+
type ProductRecommendationAlgorithmRelatedProductsQuery,
|
|
8
|
+
type ProductRecommendationAlgorithmTrendingInCategoryQuery,
|
|
9
|
+
type RequestContext,
|
|
10
|
+
} from '@reactionary/core';
|
|
11
|
+
import { MeiliSearch, type Hits, type RecordAny, type SearchParams, type SearchResponse } from 'meilisearch';
|
|
12
|
+
import type { MeilisearchConfiguration } from '../schema/configuration.schema.js';
|
|
13
|
+
|
|
14
|
+
interface MeilisearchRecommendHit {
|
|
15
|
+
id: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* MeilisearchProductRecommendationsProvider
|
|
20
|
+
*
|
|
21
|
+
* Provides product recommendations using Meilisearch's hybrid search and filtering capabilities.
|
|
22
|
+
* Supports frequentlyBoughtTogether, similar, related, and trendingInCategory algorithms.
|
|
23
|
+
*
|
|
24
|
+
* Note: This implementation uses semantic search (if AI embedding is enabled) and facet-based filtering.
|
|
25
|
+
* For production use, consider implementing more sophisticated recommendation logic or integrating
|
|
26
|
+
* with a dedicated recommendation engine.
|
|
27
|
+
*/
|
|
28
|
+
export class MeilisearchProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
29
|
+
protected config: MeilisearchConfiguration;
|
|
30
|
+
|
|
31
|
+
constructor(config: MeilisearchConfiguration, cache: Cache, context: RequestContext) {
|
|
32
|
+
super(cache, context);
|
|
33
|
+
this.config = config;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get similar product recommendations
|
|
38
|
+
* Uses semantic search to find visually or data-wise similar products
|
|
39
|
+
*/
|
|
40
|
+
protected override async getSimilarProductsRecommendations(
|
|
41
|
+
query: ProductRecommendationAlgorithmSimilarProductsQuery
|
|
42
|
+
): Promise<ProductRecommendation[]> {
|
|
43
|
+
const client = new MeiliSearch({
|
|
44
|
+
host: this.config.apiUrl,
|
|
45
|
+
apiKey: this.config.apiKey,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const index = client.index(this.config.indexName);
|
|
49
|
+
|
|
50
|
+
if (!this.config.useAIEmbedding) {
|
|
51
|
+
console.warn('AI embedding is not enabled in configuration. Similar product recommendations will be based on keyword matching, which may not provide optimal results.');
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const searchOptions: SearchParams = {
|
|
57
|
+
limit: query.numberOfRecommendations,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const response = await index.searchSimilarDocuments<MeilisearchRecommendHit>({
|
|
61
|
+
id: query.sourceProduct.key,
|
|
62
|
+
limit: query.numberOfRecommendations,
|
|
63
|
+
embedder: this.config.useAIEmbedding,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
return this.parseRecommendations(response, 'similar');
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Error fetching similar product recommendations:', error);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Maps Meilisearch search results to ProductRecommendation format
|
|
77
|
+
*/
|
|
78
|
+
protected parseRecommendations(recommendation: SearchResponse<MeilisearchRecommendHit>, algorithm: string): ProductRecommendation[] {
|
|
79
|
+
return recommendation.hits.map((hit) => ({
|
|
80
|
+
recommendationIdentifier: {
|
|
81
|
+
key: hit.id,
|
|
82
|
+
algorithm,
|
|
83
|
+
},
|
|
84
|
+
product: {
|
|
85
|
+
key: hit.id,
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
}
|