Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
c6adbf79e7 ci: Bump version to 0.1.3 [skip ci] 2025-12-25 02:26:17 +05:00
7399a27600 add ai agent fallbacks
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3h14m13s
2025-12-24 13:25:18 -08:00
5 changed files with 74 additions and 180 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.1.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -243,7 +243,9 @@ describe('App Component', () => {
mockedApiClient.fetchShoppingLists.mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify([]))),
);
mockedAiApiClient.rescanImageArea.mockResolvedValue({ text: 'mocked text' }); // Mock for FlyerCorrectionTool
mockedAiApiClient.rescanImageArea.mockResolvedValue(
new Response(JSON.stringify({ text: 'mocked text' })),
); // Mock for FlyerCorrectionTool
console.log('[TEST DEBUG] beforeEach: Setup complete');
});

View File

@@ -1,8 +1,15 @@
// src/services/aiAnalysisService.ts
import { Flyer, FlyerItem, MasterGroceryItem, GroundedResponse } from '../types';
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.
@@ -15,8 +22,7 @@ export class AiAnalysisService {
*/
async getQuickInsights(items: FlyerItem[]): Promise<string> {
logger.info('[AiAnalysisService] getQuickInsights called.');
const result = await aiApiClient.getQuickInsights(items);
return result.text;
return aiApiClient.getQuickInsights(items).then((res) => res.json());
}
/**
@@ -26,8 +32,7 @@ export class AiAnalysisService {
*/
async getDeepDiveAnalysis(items: FlyerItem[]): Promise<string> {
logger.info('[AiAnalysisService] getDeepDiveAnalysis called.');
const result = await aiApiClient.getDeepDiveAnalysis(items);
return result.text;
return aiApiClient.getDeepDiveAnalysis(items).then((res) => res.json());
}
/**
@@ -39,7 +44,18 @@ export class AiAnalysisService {
logger.info('[AiAnalysisService] searchWeb called.');
// Construct a query string from the item names.
const query = items.map((item) => item.item).join(', ');
return aiApiClient.searchWeb(query);
// 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: 'Untitled' }
: { uri: '', title: 'Untitled' }) as Source,
);
return { ...response, sources: mappedSources };
}
/**
@@ -52,7 +68,7 @@ export class AiAnalysisService {
logger.info('[AiAnalysisService] planTripWithMaps called.');
// Encapsulate geolocation logic within the service.
const userLocation = await this.getCurrentLocation();
return aiApiClient.planTripWithMaps(items, store, userLocation);
return aiApiClient.planTripWithMaps(items, store, userLocation).then((res) => res.json());
}
/**
@@ -62,7 +78,17 @@ export class AiAnalysisService {
*/
async compareWatchedItemPrices(watchedItems: MasterGroceryItem[]): Promise<GroundedResponse> {
logger.info('[AiAnalysisService] compareWatchedItemPrices called.');
return aiApiClient.compareWatchedItemPrices(watchedItems);
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: 'Untitled' }
: { uri: '', title: 'Untitled' }) as Source,
);
return { ...response, sources: mappedSources };
}
/**
@@ -72,8 +98,7 @@ export class AiAnalysisService {
*/
async generateImageFromText(prompt: string): Promise<string> {
logger.info('[AiAnalysisService] generateImageFromText called.');
const result = await aiApiClient.generateImageFromText(prompt);
return result.imageUrl;
return aiApiClient.generateImageFromText(prompt).then((res) => res.json());
}
/**

View File

@@ -104,16 +104,16 @@ export const getJobStatus = async (
}
};
export const isImageAFlyer = async (
export const isImageAFlyer = (
imageFile: File,
tokenOverride?: string,
): Promise<{ is_flyer: boolean }> => {
): Promise<Response> => {
const formData = new FormData();
formData.append('image', imageFile);
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
// The URL must be relative, as the helper constructs the full path.
const response = await apiFetch(
return apiFetch(
'/ai/check-flyer',
{
method: 'POST',
@@ -121,28 +121,16 @@ export const isImageAFlyer = async (
},
{ tokenOverride },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
export const extractAddressFromImage = async (
export const extractAddressFromImage = (
imageFile: File,
tokenOverride?: string,
): Promise<{ address: string }> => {
): Promise<Response> => {
const formData = new FormData();
formData.append('image', imageFile);
const response = await apiFetch(
return apiFetch(
'/ai/extract-address',
{
method: 'POST',
@@ -150,30 +138,18 @@ export const extractAddressFromImage = async (
},
{ tokenOverride },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
export const extractLogoFromImage = async (
export const extractLogoFromImage = (
imageFiles: File[],
tokenOverride?: string,
): Promise<{ store_logo_base_64: string | null }> => {
): Promise<Response> => {
const formData = new FormData();
imageFiles.forEach((file) => {
formData.append('images', file);
});
const response = await apiFetch(
return apiFetch(
'/ai/extract-logo',
{
method: 'POST',
@@ -181,26 +157,14 @@ export const extractLogoFromImage = async (
},
{ tokenOverride },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
export const getQuickInsights = async (
export const getQuickInsights = (
items: Partial<FlyerItem>[],
signal?: AbortSignal,
tokenOverride?: string,
): Promise<{ text: string }> => {
const response = await apiFetch(
): Promise<Response> => {
return apiFetch(
'/ai/quick-insights',
{
method: 'POST',
@@ -210,26 +174,14 @@ export const getQuickInsights = async (
},
{ tokenOverride, signal },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
export const getDeepDiveAnalysis = async (
export const getDeepDiveAnalysis = (
items: Partial<FlyerItem>[],
signal?: AbortSignal,
tokenOverride?: string,
): Promise<{ text: string }> => {
const response = await apiFetch(
): Promise<Response> => {
return apiFetch(
'/ai/deep-dive',
{
method: 'POST',
@@ -239,26 +191,14 @@ export const getDeepDiveAnalysis = async (
},
{ tokenOverride, signal },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
export const searchWeb = async (
export const searchWeb = (
query: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<GroundedResponse> => {
const response = await apiFetch(
): Promise<Response> => {
return apiFetch(
'/ai/search-web',
{
method: 'POST',
@@ -268,18 +208,6 @@ export const searchWeb = async (
},
{ tokenOverride, signal },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
// ============================================================================
@@ -292,9 +220,9 @@ export const planTripWithMaps = async (
userLocation: GeolocationCoordinates,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<GroundedResponse> => {
): Promise<Response> => {
logger.debug('Stub: planTripWithMaps called with location:', { userLocation });
const response = await apiFetch(
return apiFetch(
'/ai/plan-trip',
{
method: 'POST',
@@ -303,18 +231,6 @@ export const planTripWithMaps = async (
},
{ signal, tokenOverride },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
/**
@@ -322,13 +238,13 @@ export const planTripWithMaps = async (
* @param prompt A description of the image to generate (e.g., a meal plan).
* @returns A base64-encoded string of the generated PNG image.
*/
export const generateImageFromText = async (
export const generateImageFromText = (
prompt: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<{ imageUrl: string }> => {
): Promise<Response> => {
logger.debug('Stub: generateImageFromText called with prompt:', { prompt });
const response = await apiFetch(
return apiFetch(
'/ai/generate-image',
{
method: 'POST',
@@ -338,18 +254,6 @@ export const generateImageFromText = async (
},
{ tokenOverride, signal },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
/**
@@ -357,13 +261,13 @@ export const generateImageFromText = async (
* @param text The text to be spoken.
* @returns A base64-encoded string of the raw audio data.
*/
export const generateSpeechFromText = async (
export const generateSpeechFromText = (
text: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<{ audioUrl: string }> => {
): Promise<Response> => {
logger.debug('Stub: generateSpeechFromText called with text:', { text });
const response = await apiFetch(
return apiFetch(
'/ai/generate-speech',
{
method: 'POST',
@@ -373,18 +277,6 @@ export const generateSpeechFromText = async (
},
{ tokenOverride, signal },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
/**
@@ -426,34 +318,22 @@ export const startVoiceSession = (callbacks: {
* @param tokenOverride Optional token for testing.
* @returns A promise that resolves to the API response containing the extracted text.
*/
export const rescanImageArea = async (
export const rescanImageArea = (
imageFile: File,
cropArea: { x: number; y: number; width: number; height: number },
extractionType: 'store_name' | 'dates' | 'item_details',
tokenOverride?: string,
): Promise<{ text: string | undefined }> => {
): Promise<Response> => {
const formData = new FormData();
formData.append('image', imageFile);
formData.append('cropArea', JSON.stringify(cropArea));
formData.append('extractionType', extractionType);
const response = await apiFetch(
return apiFetch(
'/ai/rescan-area',
{ method: 'POST', body: formData },
{ tokenOverride },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
/**
@@ -461,13 +341,13 @@ export const rescanImageArea = async (
* @param watchedItems An array of the user's watched master grocery items.
* @returns A promise that resolves to the raw `Response` object from the API.
*/
export const compareWatchedItemPrices = async (
export const compareWatchedItemPrices = (
watchedItems: MasterGroceryItem[],
signal?: AbortSignal,
): Promise<GroundedResponse> => {
): Promise<Response> => {
// Use the apiFetch wrapper for consistency with other API calls in this file.
// This centralizes token handling and base URL logic.
const response = await apiFetch(
return apiFetch(
'/ai/compare-prices',
{
method: 'POST',
@@ -475,17 +355,4 @@ export const compareWatchedItemPrices = async (
body: JSON.stringify({ items: watchedItems }),
},
{ signal },
);
if (!response.ok) {
let errorBody;
try {
errorBody = await response.json();
} catch (e) {
errorBody = { message: await response.text() };
}
throw { status: response.status, body: errorBody };
}
return response.json();
};
)};