All files / src/services aiAnalysisService.ts

96.96% Statements 32/33
50% Branches 9/18
94.73% Functions 18/19
96.42% Lines 27/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117                                              1x 1x                 1x 1x                 4x   4x   4x   3x   3x   2x   4x                   3x   3x 1x                 2x 2x   1x   1x       2x                 2x 2x                 3x 3x 1x   2x 1x 1x          
// src/services/aiAnalysisService.ts
import { Flyer, FlyerItem, MasterGroceryItem, GroundedResponse, Source } from '../types';
import * as aiApiClient from './aiApiClient';
import { logger } from './logger.client';
 
interface RawSource {
  web?: {
    uri?: string;
    title?: string;
  };
}
 
/**
 * A service class to encapsulate all AI analysis API calls and related business logic.
 * This decouples the React components and hooks from the data fetching implementation.
 */
export class AiAnalysisService {
  /**
   * Fetches quick insights for a given set of flyer items.
   * @param items - The flyer items to analyze.
   * @returns The string result from the API.
   */
  async getQuickInsights(items: FlyerItem[]): Promise<string> {
    logger.info('[AiAnalysisService] getQuickInsights called.');
    return aiApiClient.getQuickInsights(items).then((res) => res.json());
  }
 
  /**
   * Fetches a deep dive analysis for a given set of flyer items.
   * @param items - The flyer items to analyze.
   * @returns The string result from the API.
   */
  async getDeepDiveAnalysis(items: FlyerItem[]): Promise<string> {
    logger.info('[AiAnalysisService] getDeepDiveAnalysis called.');
    return aiApiClient.getDeepDiveAnalysis(items).then((res) => res.json());
  }
 
  /**
   * Performs a web search based on the flyer items.
   * @param items - The flyer items to use as context for the search.
   * @returns A grounded response with text and sources.
   */
  async searchWeb(items: FlyerItem[]): Promise<GroundedResponse> {
    logger.info('[AiAnalysisService] searchWeb called.');
    // Construct a query string from the item names.
    const query = items.map((item) => item.item).join(', ');
    // The API client returns a specific shape that we need to await the JSON from
    const response: { text: string; sources: RawSource[] } = await aiApiClient
      .searchWeb(query)
      .then((res) => res.json());
    // Normalize sources to a consistent format.
    const mappedSources = (response.sources || []).map(
      (s: RawSource) =>
        (s.web ? { uri: s.web.uri || '', title: s.web.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source,
    );
    return { ...response, sources: mappedSources };
  }
 
  /**
   * Plans a shopping trip using maps and the user's location.
   * @param items - The flyer items for the trip.
   * @param store - The store associated with the flyer.
   * @returns A grounded response with the trip plan and sources.
   */
  async planTripWithMaps(items: FlyerItem[], store: Flyer['store']): Promise<GroundedResponse> {
    logger.info('[AiAnalysisService] planTripWithMaps called.');
    // Encapsulate geolocation logic within the service.
    const userLocation = await this.getCurrentLocation();
    return aiApiClient.planTripWithMaps(items, store, userLocation).then((res) => res.json());
  }
 
  /**
   * Compares prices for a user's watched items.
   * @param watchedItems - The list of master grocery items to compare.
   * @returns A grounded response with the price comparison.
   */
  async compareWatchedItemPrices(watchedItems: MasterGroceryItem[]): Promise<GroundedResponse> {
    logger.info('[AiAnalysisService] compareWatchedItemPrices called.');
    const response: { text: string; sources: RawSource[] } = await aiApiClient
      .compareWatchedItemPrices(watchedItems)
      .then((res) => res.json());
    // Normalize sources to a consistent format.
    const mappedSources = (response.sources || []).map(
      (s: RawSource) =>
        (s.web ? { uri: s.web.uri || '', title: s.web.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source,
    );
    return { ...response, sources: mappedSources };
  }
 
  /**
   * Generates an image based on a provided text prompt (e.g., a meal plan).
   * @param prompt - The text to use for image generation.
   * @returns A base64 encoded string of the generated image.
   */
  async generateImageFromText(prompt: string): Promise<string> {
    logger.info('[AiAnalysisService] generateImageFromText called.');
    return aiApiClient.generateImageFromText(prompt).then((res) => res.json());
  }
 
  /**
   * A helper to promisify the Geolocation API.
   * @returns A promise that resolves with the user's coordinates.
   * @private
   */
  private getCurrentLocation(): Promise<GeolocationCoordinates> {
    return new Promise((resolve, reject) => {
      if (!navigator.geolocation) {
        return reject(new Error('Geolocation is not supported by your browser.'));
      }
      navigator.geolocation.getCurrentPosition(
        (pos) => resolve(pos.coords),
        (err) => reject(err),
      );
    });
  }
}