@chelseaapps/recommender documentation

import { Injectable, Inject, Logger, CACHE_MANAGER } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { ApiClient, requests } from 'recombee-api-client';
import { RECOMMENDER_OPTIONS } from './constants';
import { DetailViewExistsException } from './exceptions/detail-view-exists.exception';
import { InvalidHttpMethodException } from './exceptions/invalid-http-method.exception';
import { InvalidItemIDException } from './exceptions/invalid-item-id.exception';
import { InvalidPropertyNameException } from './exceptions/invalid-property-name.exceptions';
import { InvalidRatingException } from './exceptions/invalid-rating.exception';
import { InvalidUserIDException } from './exceptions/invalid-user-id.exception';
import { ItemNotFoundException } from './exceptions/item-not-found.exception';
import { ItemsToItemInvalidArgsException } from './exceptions/items-to-items-invalid-args.exception';
import { PropertyNotFoundException } from './exceptions/property-not-found.exception';
import { RatingExistsException } from './exceptions/rating-exists.exception';
import { RecommendNextInvalidArgsException } from './exceptions/recommend-next-invalid-args.exception';
import { RecommendationNotFoundException } from './exceptions/recommendation-not-found.exception';
import { UnauthenticatedException } from './exceptions/unauthenticated.exception';
import { UserNotFoundException } from './exceptions/user-not-found.exception';
import { RecommenderOptions } from './interfaces';
import { IRecommendationsResult } from './interfaces/recommendation-result.interface';

/**
 * Interface for RecommenderService
 *
 */
interface IRecommenderService {
    setUser<T>(userID: string, details: T): Promise<boolean>;
    setItem<T>(itemID: string, details: T): Promise<boolean>;
    deleteItem(itemID: string): Promise<boolean>;
    addDetailView(userID: string, itemID: string, recommendationID: string): Promise<boolean>;
    addRating(userID: string, itemID: string, rating: number, recommendationID: string): Promise<boolean>;
    recommendItemsToUser(userID: string, count: number): Promise<IRecommendationsResult>;
    recommendNextItems(recommendationID: string, count: number): Promise<IRecommendationsResult>;
    recommendItemsToItem(itemID: string, userID: string, count: number): Promise<IRecommendationsResult>;
    searchItems(userID: string, query: string, count: number): Promise<IRecommendationsResult>;
    recommendUsersToUser(userID: string, count: number): Promise<IRecommendationsResult>;
}

@Injectable()
/**
 *  You can remove the dependencies on the Logger if you don't need it.  You can also
 *  remove the `async test()` method.
 *
 *  The only thing you need to leave intact is the `@Inject(RECOMMENDER_OPTIONS) private _recommenderOptions`.
 *
 *  That injected dependency gives you access to the options passed in to
 *  RecommenderService.
 *
 */
export class RecommenderService implements IRecommenderService {
    private readonly logger: Logger = new Logger(RecommenderService.name);

    private readonly client: ApiClient;

    constructor(
        @Inject(RECOMMENDER_OPTIONS) private options: RecommenderOptions,
        @Inject(CACHE_MANAGER) private cacheManager: Cache,
    ) {
        this.client = new ApiClient(options.databaseID, options.privateToken);
    }

    // SECTION: Set and delete users

    /**
     * Add a user to the recommendation engine
     * @param userID User ID
     * @param details Parameters to store related to the user
     * @returns Response
     */
    async setUser<T>(userID: string, details: T) {
        try {
            // Create a new user and set their values
            return await this.send(
                new requests.SetUserValues(userID, details, {
                    cascadeCreate: true,
                }),
            );
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidPropertyNameException();
                case 404:
                    throw new PropertyNotFoundException();
                default:
                    throw err;
            }
        }
    }

    // SECTION: Set and delete items

    /**
     * Set an item's properties, or create if doesn't exist
     * @param itemID Item ID
     * @param details Item properties
     * @returns Response
     */
    async setItem<T>(itemID: string, details: T) {
        try {
            // Create a new item and set its values
            return await this.send(
                new requests.SetItemValues(itemID, details, {
                    cascadeCreate: true,
                }),
            );
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidPropertyNameException();
                case 404:
                    throw new PropertyNotFoundException();
                default:
                    throw err;
            }
        }
    }

    /**
     * Delete an item
     * @param itemID Item ID
     * @returns Response
     */
    async deleteItem(itemID: string) {
        try {
            // Create a new item and set its values
            return await this.send(new requests.DeleteItem(itemID));
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidItemIDException();
                case 404:
                    throw new ItemNotFoundException();
                default:
                    throw err;
            }
        }
    }

    // SECTION: User feedback on items
    /**
     * Register a detail view by a user on an item
     * @param userID User ID
     * @param itemID Item ID
     * @param recommendationID Recommendation ID to link up to recommendation
     * @returns Response
     */
    async addDetailView(userID: string, itemID: string, recommendationID: string) {
        try {
            // Add detail view
            return await this.send(
                new requests.AddDetailView(userID, itemID, {
                    recommId: recommendationID,
                }),
            );
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidItemIDException();
                case 404:
                    throw new ItemNotFoundException();
                case 409:
                    throw new DetailViewExistsException();
                default:
                    throw err;
            }
        }
    }

    /**
     * Adds a rating of given item made by a given user.
     * @param userID - User who submitted the rating
     * @param itemID - Rated item
     * @param rating - Rating rescaled to interval [-1.0,1.0], where -1.0 means the worst rating possible, 0.0 means neutral, and 1.0 means absolutely positive rating. For example, in the case of 5-star evaluations, rating = (numStars-3)/2 formula may be used for the conversion.
     * @param recommendationID - If this rating is based on a recommendation request, `recommId` is the id of the clicked recommendation.
     */
    async addRating(userID: string, itemID: string, rating: number, recommendationID: string) {
        if (rating < -1 || rating > 1) throw new InvalidRatingException();

        try {
            // Add detail view
            return await this.send(
                new requests.AddRating(userID, itemID, rating, {
                    recommId: recommendationID,
                }),
            );
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidItemIDException();
                case 404:
                    throw new ItemNotFoundException();
                case 409:
                    throw new RatingExistsException();
                default:
                    throw err;
            }
        }
    }

    // SECTION: Recommend items to users
    /**
     * Returns items that shall be shown to a user as next recommendations when the user e.g. scrolls the page down (*infinite scroll*) or goes to a next page.
     * It accepts `recommId` of a base recommendation request (e.g. request from the first page) and number of items that shall be returned (`count`).
     * The base request can be one of:
     *   - [Recommend items to item](https://docs.recombee.com/api.html#recommend-items-to-item)
     *   - [Recommend items to user](https://docs.recombee.com/api.html#recommend-items-to-user)
     *   - [Search items](https://docs.recombee.com/api.html#search-items)
     * All the other parameters are inherited from the base request.
     * *Recommend next items* can be called many times for a single `recommId` and each call returns different (previously not recommended) items.
     * The number of *Recommend next items* calls performed so far is returned in the `numberNextRecommsCalls` field.
     * *Recommend next items* can be requested up to 30 minutes after the base request or a previous *Recommend next items* call.
     * For billing purposes, each call to *Recommend next items* is counted as a separate recommendation request.
     */
    async recommendItemsToUser(userID: string, count: number): Promise<IRecommendationsResult> {
        const cacheKey = `recommendItemsToUser:${userID}:${count}`;
        const cachedValue = await this.get<IRecommendationsResult>(cacheKey);
        if (cachedValue) return cachedValue;

        try {
            const result = await this.send(
                new requests.RecommendItemsToUser(userID, count, {
                    scenario: this.options.scenarios.main,
                }),
            );

            await this.set(cacheKey, result);

            return result;
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidUserIDException();
                case 404:
                    throw new UserNotFoundException();
                default:
                    throw err;
            }
        }
    }

    /**
     * Returns items that shall be shown to a user as next recommendations when the user e.g. scrolls the page down (*infinite scroll*) or goes to a next page.
     * @description
     * It accepts `recommId` of a base recommendation request (e.g. request from the first page) and number of items that shall be returned (`count`).
     * The base request can be one of:
     *   - [Recommend items to item](https://docs.recombee.com/api.html#recommend-items-to-item)
     *   - [Recommend items to user](https://docs.recombee.com/api.html#recommend-items-to-user)
     *   - [Search items](https://docs.recombee.com/api.html#search-items)
     * All the other parameters are inherited from the base request.
     * *Recommend next items* can be called many times for a single `recommId` and each call returns different (previously not recommended) items.
     * The number of *Recommend next items* calls performed so far is returned in the `numberNextRecommsCalls` field.
     * *Recommend next items* can be requested up to 30 minutes after the base request or a previous *Recommend next items* call.
     * For billing purposes, each call to *Recommend next items* is counted as a separate recommendation request.
     * @param recommendationID ID of the base recommendation request for which next recommendations should be returned
     * @param count Number of items to be recommended
     */
    async recommendNextItems(recommendationID: string, count: number) {
        try {
            return await this.send(new requests.RecommendNextItems(recommendationID, count));
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new RecommendNextInvalidArgsException();
                case 404:
                    throw new RecommendationNotFoundException();
                default:
                    throw err;
            }
        }
    }

    /**
	 * Recommends set of items that are somehow related to one given item, *X*. Typical scenario  is when user *A* is viewing *X*. Then you may display items to the user that he might be also interested in. Recommend items to item request gives you Top-N such items, optionally taking the target user *A* into account.
	 * The returned items are sorted by relevance (first item being the most relevant).
	 * Besides the recommended items, also a unique `recommId` is returned in the response. It can be used to:
	 * - Let Recombee know that this recommendation was successful (e.g. user clicked one of the recommended items). See [Reported metrics](https://docs.recombee.com/admin_ui.html#reported-metrics).
	 * - Get subsequent recommended items when the user scrolls down (*infinite scroll*) or goes to the next page. See [Recommend Next Items](https://docs.recombee.com/api.html#recommend-next-items).
	 * It is also possible to use POST HTTP method (for example in case of very long ReQL filter) - query parameters then become body parameters.
	 * @param itemID - ID of the item for which the recommendations are to be generated.
	 * @param userID - ID of the user who will see the recommendations.
     * Specifying the *userID* is beneficial because:
     * It makes the recommendations personalized
     * Allows the calculation of Actions and Conversions
     *   in the graphical user interface,
     *   as Recombee can pair the user who got recommendations
     *   and who afterwards viewed/purchased an item.
     * If you insist on not specifying the user, pass `null`
     * (`None`, `nil`, `NULL` etc. depending on language) to *userID*.
     * Do not create some special dummy user for getting recommendations,
     * as it could mislead the recommendation models,
     * and result in wrong recommendations.
     * For anonymous/unregistered users it is possible to use for example their session ID.
     * @param count - Number of items to be recommended (N for the top-N recommendation).

	 */
    async recommendItemsToItem(itemID: string, userID: string | null, count: number): Promise<IRecommendationsResult> {
        const cacheKey = `recommendItemsToItem:${itemID}:${userID}:${count}`;
        const cachedValue = await this.get<IRecommendationsResult>(cacheKey);
        if (cachedValue) return cachedValue;

        try {
            const result = await this.send(
                new requests.RecommendItemsToItem(itemID, userID, count, {
                    scenario: this.options.scenarios.items,
                }),
            );

            await this.set(cacheKey, result);

            return result;
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new ItemsToItemInvalidArgsException();
                case 404:
                    throw new ItemNotFoundException();
                default:
                    throw err;
            }
        }
    }

    // SECTION: Search items

    /**
     * Full-text personalized search. The results are based on the provided `searchQuery` and also on the user's past interactions (purchases, ratings, etc.) with the items (items more suitable for the user are preferred in the results).
     * @description
     * All the string and set item properties are indexed by the search engine.
     * This endpoint should be used in a search box at your website/app. It can be called multiple times as the user is typing the query in order to get the most viable suggestions based on current state of the query, or once after submitting the whole query.
     * The returned items are sorted by relevance (first item being the most relevant).
     * Besides the recommended items, also a unique `recommId` is returned in the response. It can be used to:
     * - Let Recombee know that this search was successful (e.g. user clicked one of the recommended items). See [Reported metrics](https://docs.recombee.com/admin_ui.html#reported-metrics).
     * - Get subsequent search results when the user scrolls down or goes to the next page. See [Recommend Next Items](https://docs.recombee.com/api.html#recommend-next-items).
     * It is also possible to use POST HTTP method (for example in case of very long ReQL filter) - query parameters then become body parameters.
     * @param userID - ID of the user for whom personalized search will be performed.
     * @param query - Search query provided by the user. It is used for the full-text search.
     * @param count - Number of items to be returned (N for the top-N results).
     */
    async searchItems(userID: string, query: string, count: number): Promise<IRecommendationsResult> {
        const cacheKey = `searchItems:${userID}:${query.trim().toLowerCase()}:${count}`;
        const cachedValue = await this.get<IRecommendationsResult>(cacheKey);
        if (cachedValue) return cachedValue;

        try {
            const result = await this.send(
                new requests.SearchItems(userID, query, count, {
                    scenario: this.options.scenarios.search,
                }),
            );

            await this.set(cacheKey, result);

            return result;
        } catch (err) {
            console.log(err);
            switch (err.statusCode) {
                case 400:
                    throw new InvalidUserIDException();
                case 404:
                    throw new UserNotFoundException();
                default:
                    throw err;
            }
        }
    }

    // SECTION: Recommend users to users

    /**
     * Get similar users as some given user, based on the user's past interactions (purchases, ratings, etc.) and values of properties.
     * It is also possible to use POST HTTP method (for example in case of very long ReQL filter) - query parameters then become body parameters.
     * The returned users are sorted by similarity (first user being the most similar).
     */
    async recommendUsersToUser(userID: string, count: number) {
        const cacheKey = `recommendUsersToUser:${userID}:${count}`;
        const cachedValue = await this.get<IRecommendationsResult>(cacheKey);
        if (cachedValue) return cachedValue;

        try {
            const result = await this.send(
                new requests.RecommendUsersToUser(userID, count, {
                    scenario: this.options.scenarios.users,
                }),
            );

            return result;
        } catch (err) {
            switch (err.statusCode) {
                case 400:
                    throw new InvalidUserIDException();
                case 404:
                    throw new UserNotFoundException();
                default:
                    throw err;
            }
        }
    }

    /**
     * Send a request. Catches default errors (401 & 405)
     * @param request Request instance
     * @returns Result from request
     */
    private async send(request: requests.Request) {
        try {
            return await this.client.send(request);
        } catch (err) {
            switch (err.statusCode) {
                case 401:
                    throw new UnauthenticatedException();
                case 405:
                    throw new InvalidHttpMethodException();
                default:
                    throw err;
            }
        }
    }

    /**
     * Store an object in the cache for the default amount of time
     * @param key Key
     * @param value Object value
     */
    private async set(key: string, value: any) {
        if (this.options.cache.enabled)
            await this.cacheManager.set(key, JSON.stringify(value), {
                ttl: this.options.cache.ttl,
            });
    }

    /**
     * Get an object from the cache by a given key
     * @param key Key
     * @returns Object cast to `T`
     */
    private async get<T>(key: string) {
        if (!this.options.cache.enabled) return undefined;

        const value = await this.cacheManager.get<string>(key);

        if (!value) return undefined;

        return JSON.parse(value) as T;
    }
}

Was this helpful?