@nyris/nyris-webapp 0.3.98 → 0.4.0
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/build/asset-manifest.json +7 -6
- package/build/index.html +1 -1
- package/build/js/settings.example.js +42 -0
- package/build/static/css/main.a7ff7a7f.css +4 -0
- package/build/static/css/main.a7ff7a7f.css.map +1 -0
- package/build/static/js/main.916c157c.js +3 -0
- package/build/static/js/{main.93fdbd6e.js.map → main.916c157c.js.map} +1 -1
- package/build/static/media/plate_info.6beaea36d35053be3a45.png +0 -0
- package/package.json +3 -3
- package/public/js/settings.example.js +42 -0
- package/src/common/assets/images/plate_info.png +0 -0
- package/src/components/Cart.tsx +41 -0
- package/src/components/ChatAssistant/ChatAssistant.test.tsx +213 -0
- package/src/components/ChatAssistant/ChatAssistant.tsx +18 -1
- package/src/components/ChatAssistant/MobileChatAssistant.tsx +18 -1
- package/src/components/ChatAssistant/OptionChip.tsx +23 -1
- package/src/components/ChatAssistant/useChatAssistantLogic.ts +35 -14
- package/src/components/GoBackButton.tsx +29 -2
- package/src/components/Header.tsx +3 -0
- package/src/components/HitsPerPage.tsx +55 -18
- package/src/components/ImagePreview.tsx +31 -1
- package/src/components/ImageUpload.tsx +12 -1
- package/src/components/Inquiry/InquiryModal.tsx +61 -26
- package/src/components/Pagination.tsx +33 -6
- package/src/components/PostFilter/PostFilter.tsx +8 -1
- package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
- package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
- package/src/components/PreFilter/PreFilter.tsx +10 -2
- package/src/components/Product/Product.tsx +60 -10
- package/src/components/Product/ProductDetailView.tsx +23 -1
- package/src/components/Product/ProductDetailViewModal.tsx +5 -1
- package/src/components/Product/ProductList.tsx +51 -18
- package/src/components/Rfq/RfqModal.tsx +3 -0
- package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
- package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
- package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
- package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
- package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
- package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
- package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
- package/src/components/SidePanel.tsx +33 -0
- package/src/components/TextSearch.tsx +77 -11
- package/src/components/Tooltip/TooltipComponent.tsx +12 -4
- package/src/components/TypePlateUpload.tsx +132 -0
- package/src/hooks/chatProductMetadata.test.ts +307 -0
- package/src/hooks/useBadResultsRecovery.ts +26 -49
- package/src/hooks/useEffectiveGroundingResults.ts +8 -6
- package/src/hooks/useGoodResultsChat.ts +28 -39
- package/src/hooks/useImageSearch.ts +290 -12
- package/src/hooks/useMachinePartsListingPagination.ts +95 -0
- package/src/hooks/useResultEvaluator.ts +63 -73
- package/src/index.css +1 -1
- package/src/layouts/AppLayout.tsx +43 -3
- package/src/pages/Home.tsx +60 -14
- package/src/pages/Result.tsx +194 -14
- package/src/services/Feedback.ts +225 -34
- package/src/services/feedbackSubtypes.ts +63 -0
- package/src/services/filter.ts +1 -1
- package/src/services/initialMachinePartsText.payload.test.ts +61 -0
- package/src/services/initialMachinePartsText.test.ts +33 -0
- package/src/services/initialMachinePartsText.ts +11 -0
- package/src/services/machinePartsPage.test.ts +74 -0
- package/src/services/machinePartsPage.ts +120 -0
- package/src/services/normalizeChatResponse.ts +26 -0
- package/src/services/scopeLookup.test.ts +67 -0
- package/src/services/scopeLookup.ts +39 -0
- package/src/services/visualSearch.listingOptions.test.ts +79 -0
- package/src/services/visualSearch.ts +71 -3
- package/src/services/vizo.ts +4 -3
- package/src/stores/chat/conversationStore.test.ts +133 -0
- package/src/stores/chat/conversationStore.ts +55 -14
- package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
- package/src/stores/request/filter/filter.initialState.ts +1 -0
- package/src/stores/request/filter/filter.slice.ts +2 -0
- package/src/stores/result/prodcuts/products.initialState.ts +1 -0
- package/src/stores/result/prodcuts/products.slice.ts +4 -0
- package/src/stores/scope/scopeStore.ts +67 -4
- package/src/stores/types.ts +19 -14
- package/src/translations.ts +213 -0
- package/src/types.ts +41 -0
- package/src/utils/chatProductMetadata.test.ts +130 -0
- package/src/utils/chatProductMetadata.ts +93 -0
- package/src/utils/compressImage.ts +3 -3
- package/src/utils/fetchProductImage.ts +1 -0
- package/src/utils/normalizeChatAssistantResponse.ts +227 -0
- package/src/utils/productCardImageUrl.test.ts +41 -0
- package/src/utils/productCardImageUrl.ts +49 -0
- package/src/utils/textSearchFilter.test.ts +41 -0
- package/src/utils/textSearchFilter.ts +13 -6
- package/build/static/css/main.81216c5d.css +0 -4
- package/build/static/css/main.81216c5d.css.map +0 -1
- package/build/static/js/main.93fdbd6e.js +0 -3
- /package/build/static/js/{main.93fdbd6e.js.LICENSE.txt → main.916c157c.js.LICENSE.txt} +0 -0
|
@@ -13,20 +13,28 @@ function Tooltip({
|
|
|
13
13
|
disabled,
|
|
14
14
|
sideOffset,
|
|
15
15
|
delayDuration = 80,
|
|
16
|
+
open,
|
|
17
|
+
side = 'top',
|
|
16
18
|
}: {
|
|
17
|
-
content:
|
|
19
|
+
content: React.ReactNode;
|
|
18
20
|
children: React.ReactNode;
|
|
19
21
|
disabled?: boolean;
|
|
20
22
|
sideOffset?: number;
|
|
21
23
|
delayDuration?: number;
|
|
24
|
+
open?: boolean;
|
|
25
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
22
26
|
}) {
|
|
23
27
|
return (
|
|
24
28
|
<TooltipProvider delayDuration={delayDuration}>
|
|
25
|
-
<RadixTooltip open={disabled ? false :
|
|
29
|
+
<RadixTooltip open={disabled ? false : open}>
|
|
26
30
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
27
|
-
<TooltipContent sideOffset={sideOffset}>
|
|
31
|
+
<TooltipContent side={side} sideOffset={sideOffset}>
|
|
28
32
|
<TooltipArrow />
|
|
29
|
-
|
|
33
|
+
{typeof content === 'string' ? (
|
|
34
|
+
<p className="text-white max-w-[350px]">{content}</p>
|
|
35
|
+
) : (
|
|
36
|
+
content
|
|
37
|
+
)}
|
|
30
38
|
</TooltipContent>
|
|
31
39
|
</RadixTooltip>
|
|
32
40
|
</TooltipProvider>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Icon } from '@nyris/nyris-react-components';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { twMerge } from 'tailwind-merge';
|
|
5
|
+
import useScopeStore from 'stores/scope/scopeStore';
|
|
6
|
+
import Tooltip from './Tooltip/TooltipComponent';
|
|
7
|
+
import { ScopeSelectModal } from './ScopeSelect/ScopeSelectModal';
|
|
8
|
+
|
|
9
|
+
export interface TypePlateUploadProps {
|
|
10
|
+
className?: string;
|
|
11
|
+
disabledTooltip?: boolean;
|
|
12
|
+
alternativeView?: boolean;
|
|
13
|
+
/** When false, the button only opens a modal owned by the parent. */
|
|
14
|
+
showModal?: boolean;
|
|
15
|
+
open?: boolean;
|
|
16
|
+
onOpenChange?: (open: boolean) => void;
|
|
17
|
+
/** Results-page text search: icon, tooltip, and active-scope marker. */
|
|
18
|
+
iconOnly?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function TypePlateUpload({
|
|
22
|
+
className,
|
|
23
|
+
alternativeView = false,
|
|
24
|
+
showModal = true,
|
|
25
|
+
open,
|
|
26
|
+
onOpenChange,
|
|
27
|
+
iconOnly = false,
|
|
28
|
+
}: TypePlateUploadProps = {}) {
|
|
29
|
+
const { t } = useTranslation();
|
|
30
|
+
const scopeId = useScopeStore(state => state.scopeId);
|
|
31
|
+
const scopeItemKeys = useScopeStore(state => state.itemKeys);
|
|
32
|
+
const hasActiveScope = Boolean(scopeId) && scopeItemKeys.length > 0;
|
|
33
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
34
|
+
const isControlled = open !== undefined;
|
|
35
|
+
const isOpen = isControlled ? open : internalOpen;
|
|
36
|
+
const setIsOpen = (next: boolean) => {
|
|
37
|
+
if (!isControlled) {
|
|
38
|
+
setInternalOpen(next);
|
|
39
|
+
}
|
|
40
|
+
onOpenChange?.(next);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!window.settings.scopes?.scopeID) return null;
|
|
44
|
+
|
|
45
|
+
const buttonLabel =
|
|
46
|
+
window.settings.scopes?.typePlateButtonLabel ||
|
|
47
|
+
window.settings.scopes?.typePlateButtonText ||
|
|
48
|
+
'Scan type plate';
|
|
49
|
+
const tooltipText =
|
|
50
|
+
iconOnly && hasActiveScope
|
|
51
|
+
? t(
|
|
52
|
+
'Searching within parts for {{scopeId}}. You may scan another type plate.',
|
|
53
|
+
{ scopeId },
|
|
54
|
+
)
|
|
55
|
+
: t('Scan Type plate');
|
|
56
|
+
const accessibleName = iconOnly ? tooltipText : t(buttonLabel);
|
|
57
|
+
|
|
58
|
+
const button = (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className={twMerge(
|
|
62
|
+
'p-2 desktop:p-3 flex items-center gap-2 rounded-full border border-solid cursor-pointer',
|
|
63
|
+
alternativeView
|
|
64
|
+
? 'bg-theme-primary border-theme-primary'
|
|
65
|
+
: 'bg-[#f3f3f5] border-[#2B2C46] desktop:bg-[#FAFAFA]',
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
aria-label={accessibleName}
|
|
69
|
+
onClick={() => setIsOpen(true)}
|
|
70
|
+
>
|
|
71
|
+
<Icon
|
|
72
|
+
name="typeplate"
|
|
73
|
+
width={16}
|
|
74
|
+
height={16}
|
|
75
|
+
fill={alternativeView ? '#FFFFFF' : '#2B2C46'}
|
|
76
|
+
/>
|
|
77
|
+
{!iconOnly && (
|
|
78
|
+
<span
|
|
79
|
+
className={twMerge(
|
|
80
|
+
'whitespace-nowrap pr-1 text-[14px] leading-none',
|
|
81
|
+
alternativeView ? 'text-white' : 'text-[#2B2C46]',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{t(buttonLabel)}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
{iconOnly ? (
|
|
93
|
+
<div className="relative">
|
|
94
|
+
<Tooltip content={tooltipText}>{button}</Tooltip>
|
|
95
|
+
{hasActiveScope && (
|
|
96
|
+
<div
|
|
97
|
+
className="pointer-events-none top-2 left-8 desktop:top-[1px] desktop:left-[26px]"
|
|
98
|
+
style={{
|
|
99
|
+
position: 'absolute',
|
|
100
|
+
display: 'flex',
|
|
101
|
+
justifyContent: 'center',
|
|
102
|
+
alignItems: 'center',
|
|
103
|
+
background: 'white',
|
|
104
|
+
width: '12px',
|
|
105
|
+
height: '12px',
|
|
106
|
+
borderRadius: '100%',
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
style={{
|
|
111
|
+
width: '8px',
|
|
112
|
+
height: '8px',
|
|
113
|
+
background: window.settings.theme?.primaryColor,
|
|
114
|
+
borderRadius: '100%',
|
|
115
|
+
strokeWidth: '2px',
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
button
|
|
123
|
+
)}
|
|
124
|
+
{showModal && (
|
|
125
|
+
<ScopeSelectModal
|
|
126
|
+
openModal={isOpen}
|
|
127
|
+
handleClose={() => setIsOpen(false)}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { chatAnalysis } from 'services/vizo';
|
|
3
|
+
import useConversationStore from 'stores/chat/conversationStore';
|
|
4
|
+
import { compressImage } from 'utils/compressImage';
|
|
5
|
+
import {
|
|
6
|
+
fetchProductImageBase64,
|
|
7
|
+
getProductImageUrl,
|
|
8
|
+
} from 'utils/fetchProductImage';
|
|
9
|
+
import { loadRelatedPartsTable } from 'utils/relatedParts';
|
|
10
|
+
import { useChatAssistantLogic } from 'components/ChatAssistant/useChatAssistantLogic';
|
|
11
|
+
import { useBadResultsRecovery } from './useBadResultsRecovery';
|
|
12
|
+
import { useGoodResultsChat } from './useGoodResultsChat';
|
|
13
|
+
import { useResultEvaluator } from './useResultEvaluator';
|
|
14
|
+
|
|
15
|
+
const mockProduct = {
|
|
16
|
+
sku: 'pump-1',
|
|
17
|
+
title: 'Pump',
|
|
18
|
+
description: 'Complete technical specification. '.repeat(20),
|
|
19
|
+
customIds: { mpn: 'MPN-1', gtin: '123456' },
|
|
20
|
+
attributes: { pressure: 0, available: false, material: 'steel' },
|
|
21
|
+
metadata: { customerAttribute: 'custom-value' },
|
|
22
|
+
categories: ['Industrial', 'Pumps'],
|
|
23
|
+
_tags: { productType: 'pump', color: 'blue', shape: 'round' },
|
|
24
|
+
url: 'https://example.com/product',
|
|
25
|
+
imageUrl: 'https://example.com/image.jpg',
|
|
26
|
+
images: [{ url: 'https://example.com/image.jpg', width: 300 }],
|
|
27
|
+
};
|
|
28
|
+
const mockResultState = {
|
|
29
|
+
productsFromFindApi: [mockProduct],
|
|
30
|
+
productsFromAlgolia: [] as typeof mockProduct[],
|
|
31
|
+
firstSearchResults: [mockProduct],
|
|
32
|
+
fullResultPool: [mockProduct],
|
|
33
|
+
imageAnalysis: {},
|
|
34
|
+
setFindApiProducts: jest.fn(),
|
|
35
|
+
setFullResultPool: jest.fn(),
|
|
36
|
+
setImageAnalysis: jest.fn(),
|
|
37
|
+
};
|
|
38
|
+
const mockRequestState = { requestImages: [], regions: [] };
|
|
39
|
+
|
|
40
|
+
jest.mock('stores/result/resultStore', () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
default: Object.assign(
|
|
43
|
+
(selector: (state: typeof mockResultState) => unknown) =>
|
|
44
|
+
selector(mockResultState),
|
|
45
|
+
{ getState: () => mockResultState },
|
|
46
|
+
),
|
|
47
|
+
}));
|
|
48
|
+
jest.mock('stores/request/requestStore', () => ({
|
|
49
|
+
__esModule: true,
|
|
50
|
+
default: Object.assign(
|
|
51
|
+
(selector: (state: typeof mockRequestState) => unknown) =>
|
|
52
|
+
selector(mockRequestState),
|
|
53
|
+
{ getState: () => mockRequestState },
|
|
54
|
+
),
|
|
55
|
+
}));
|
|
56
|
+
jest.mock('stores/smartFilters/smartFiltersStore', () => ({
|
|
57
|
+
__esModule: true,
|
|
58
|
+
default: () => ({ filters: [] }),
|
|
59
|
+
}));
|
|
60
|
+
jest.mock('services/vizo', () => ({ chatAnalysis: jest.fn() }));
|
|
61
|
+
jest.mock('utils/compressImage', () => ({ compressImage: jest.fn() }));
|
|
62
|
+
jest.mock('utils/fetchProductImage', () => ({
|
|
63
|
+
fetchProductImageBase64: jest.fn(),
|
|
64
|
+
getProductImageUrl: jest.fn(),
|
|
65
|
+
}));
|
|
66
|
+
jest.mock('utils/relatedParts', () => ({
|
|
67
|
+
loadRelatedPartsTable: jest.fn(),
|
|
68
|
+
lookupRelatedParts: jest.fn(),
|
|
69
|
+
}));
|
|
70
|
+
jest.mock('utils/nyrisApiKey', () => ({ getEffectiveApiKey: jest.fn() }));
|
|
71
|
+
|
|
72
|
+
const mockChatAnalysis = chatAnalysis as jest.MockedFunction<typeof chatAnalysis>;
|
|
73
|
+
const mockFetchImage = fetchProductImageBase64 as jest.MockedFunction<
|
|
74
|
+
typeof fetchProductImageBase64
|
|
75
|
+
>;
|
|
76
|
+
const mockGetImageUrl = getProductImageUrl as jest.MockedFunction<
|
|
77
|
+
typeof getProductImageUrl
|
|
78
|
+
>;
|
|
79
|
+
const mockLoadRelatedParts = loadRelatedPartsTable as jest.MockedFunction<
|
|
80
|
+
typeof loadRelatedPartsTable
|
|
81
|
+
>;
|
|
82
|
+
const mockCompressImage = compressImage as jest.MockedFunction<
|
|
83
|
+
typeof compressImage
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
jest.clearAllMocks();
|
|
88
|
+
window.settings = {
|
|
89
|
+
algolia: { enabled: false },
|
|
90
|
+
vizo: { findRelatedParts: false },
|
|
91
|
+
} as typeof window.settings;
|
|
92
|
+
mockResultState.productsFromFindApi = [mockProduct];
|
|
93
|
+
mockResultState.productsFromAlgolia = [];
|
|
94
|
+
mockResultState.firstSearchResults = [mockProduct];
|
|
95
|
+
mockResultState.fullResultPool = [mockProduct];
|
|
96
|
+
useConversationStore.getState().reset();
|
|
97
|
+
mockGetImageUrl.mockReturnValue(mockProduct.imageUrl);
|
|
98
|
+
mockFetchImage.mockResolvedValue(null);
|
|
99
|
+
mockLoadRelatedParts.mockResolvedValue({});
|
|
100
|
+
mockCompressImage.mockResolvedValue('data:image/jpeg;base64,new-image');
|
|
101
|
+
mockChatAnalysis.mockResolvedValue({
|
|
102
|
+
quality: 'good',
|
|
103
|
+
route: 'P3',
|
|
104
|
+
opening_message: 'How can I help?',
|
|
105
|
+
message: 'How can I help?',
|
|
106
|
+
action: { type: 'none' },
|
|
107
|
+
options: [],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function expectCompleteMetadata(promptKey: string) {
|
|
112
|
+
expect(mockChatAnalysis).toHaveBeenCalledTimes(1);
|
|
113
|
+
const request = mockChatAnalysis.mock.calls[0][0];
|
|
114
|
+
expect(request.promptKey).toBe(promptKey);
|
|
115
|
+
const text = request.multimodalInputs.map(input => input.metadata).join('\n');
|
|
116
|
+
expect(text).toContain(JSON.stringify(mockProduct.description));
|
|
117
|
+
expect(text).toContain(JSON.stringify(mockProduct.customIds));
|
|
118
|
+
expect(text).toContain(JSON.stringify(mockProduct.attributes));
|
|
119
|
+
expect(text).toContain(JSON.stringify(mockProduct.metadata));
|
|
120
|
+
expect(text).toContain(JSON.stringify(mockProduct.categories));
|
|
121
|
+
expect(text).toContain(JSON.stringify(mockProduct._tags));
|
|
122
|
+
expect(text).toContain('"images":[{"width":300}]');
|
|
123
|
+
expect(text).not.toContain('https://');
|
|
124
|
+
expect(text).not.toContain('"imageUrl"');
|
|
125
|
+
expect(text).not.toContain('"url"');
|
|
126
|
+
expect(mockProduct.url).toBe('https://example.com/product');
|
|
127
|
+
return request;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function expectProductEnvelope(text: string, key: string, value: string | number) {
|
|
131
|
+
const product = JSON.parse(text.replace(/\. Result_images\.$/, ''));
|
|
132
|
+
expect(Object.keys(product).sort()).toEqual([key, 'metadata'].sort());
|
|
133
|
+
expect(product[key]).toBe(value);
|
|
134
|
+
expect(product.metadata.title).toBe(mockProduct.title);
|
|
135
|
+
expect(product.metadata.description).toBe(mockProduct.description);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test.each([
|
|
139
|
+
{ phase: 'evaluation', limit: 30 },
|
|
140
|
+
{ phase: 'recovery', limit: 100 },
|
|
141
|
+
])('$phase counts curated traits within its result limit', async ({ phase, limit }) => {
|
|
142
|
+
const products = Array.from({ length: limit + 1 }, (_, index) => ({
|
|
143
|
+
...mockProduct,
|
|
144
|
+
sku: `pump-${index}`,
|
|
145
|
+
title: `ACME pump 24V DC ${index}`,
|
|
146
|
+
description: `steel housing ${index}`,
|
|
147
|
+
material: { name: 'steel' },
|
|
148
|
+
brand: { name: 'ACME' },
|
|
149
|
+
voltage: ['24V DC'],
|
|
150
|
+
}));
|
|
151
|
+
mockResultState.productsFromFindApi = products;
|
|
152
|
+
const snapshot = JSON.stringify(products);
|
|
153
|
+
const { result } = renderHook(() => ({
|
|
154
|
+
evaluator: useResultEvaluator(),
|
|
155
|
+
recovery: useBadResultsRecovery(),
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
await act(async () => {
|
|
159
|
+
if (phase === 'evaluation') {
|
|
160
|
+
await result.current.evaluator.evaluateResults({
|
|
161
|
+
imageAnalysis: {},
|
|
162
|
+
activeProducts: products,
|
|
163
|
+
query: 'pump',
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
await result.current.recovery.sendMessage('Find a pump');
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(mockChatAnalysis).toHaveBeenCalledTimes(1);
|
|
171
|
+
const request = mockChatAnalysis.mock.calls[0][0];
|
|
172
|
+
const context = request.multimodalInputs[0].metadata;
|
|
173
|
+
const frequency = JSON.parse(
|
|
174
|
+
context.split(/COMMON ATTRIBUTES FOUND IN RESULTS[^\n]*\n/)[1].split('\n\n')[0],
|
|
175
|
+
);
|
|
176
|
+
expect(frequency).toEqual({
|
|
177
|
+
brand: { ACME: limit },
|
|
178
|
+
voltage: { '24V DC': limit },
|
|
179
|
+
product_type: { PUMP: limit },
|
|
180
|
+
material: { steel: limit },
|
|
181
|
+
});
|
|
182
|
+
const product = JSON.parse(
|
|
183
|
+
request.multimodalInputs[1].metadata.replace(/\. Result_images\.$/, ''),
|
|
184
|
+
);
|
|
185
|
+
expect(product.metadata.sku).toBe('pump-0');
|
|
186
|
+
expect(product.metadata.description).toBe('steel housing 0');
|
|
187
|
+
expect(product.metadata.attributes).toEqual(mockProduct.attributes);
|
|
188
|
+
expect(product.metadata.customIds).toEqual(mockProduct.customIds);
|
|
189
|
+
expect(product.metadata.brand).toEqual({ name: 'ACME' });
|
|
190
|
+
expect(product.metadata.voltage).toEqual(['24V DC']);
|
|
191
|
+
expect(product.metadata.material).toEqual({ name: 'steel' });
|
|
192
|
+
expect(Object.keys(product).sort()).toEqual(
|
|
193
|
+
[phase === 'evaluation' ? 'rank' : 'index', 'metadata'].sort(),
|
|
194
|
+
);
|
|
195
|
+
expect(JSON.stringify(products)).toBe(snapshot);
|
|
196
|
+
expect(useConversationStore.getState().error).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test.each([false, true])(
|
|
200
|
+
'evaluation includes complete metadata even when thumbnails are available: %s',
|
|
201
|
+
async hasImages => {
|
|
202
|
+
mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
|
|
203
|
+
const { result } = renderHook(() => useResultEvaluator());
|
|
204
|
+
await act(async () => {
|
|
205
|
+
await result.current.evaluateResults({
|
|
206
|
+
imageAnalysis: {},
|
|
207
|
+
activeProducts: [mockProduct],
|
|
208
|
+
query: 'pump',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
const request = expectCompleteMetadata('result_evaluator_prompt');
|
|
212
|
+
expectProductEnvelope(request.multimodalInputs[1].metadata, 'rank', 1);
|
|
213
|
+
expect(request.multimodalInputs[1].image).toBe(
|
|
214
|
+
hasImages ? 'product-image' : undefined,
|
|
215
|
+
);
|
|
216
|
+
expect(useConversationStore.getState().error).toBeNull();
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
test.each([
|
|
221
|
+
{ silent: false, algolia: false, hasImages: false },
|
|
222
|
+
{ silent: true, algolia: false, hasImages: true },
|
|
223
|
+
{ silent: false, algolia: true, hasImages: true },
|
|
224
|
+
])('chat sends full metadata for %j', async ({ silent, algolia, hasImages }) => {
|
|
225
|
+
window.settings.algolia.enabled = algolia;
|
|
226
|
+
if (algolia) {
|
|
227
|
+
mockResultState.productsFromAlgolia = [
|
|
228
|
+
mockProduct,
|
|
229
|
+
mockProduct,
|
|
230
|
+
mockProduct,
|
|
231
|
+
];
|
|
232
|
+
mockResultState.fullResultPool = [];
|
|
233
|
+
} else {
|
|
234
|
+
mockResultState.fullResultPool = [mockProduct, mockProduct, mockProduct];
|
|
235
|
+
}
|
|
236
|
+
mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
|
|
237
|
+
const { result } = renderHook(() => useGoodResultsChat());
|
|
238
|
+
await act(async () => {
|
|
239
|
+
await result.current.sendMessage('Find a pump', { silent });
|
|
240
|
+
});
|
|
241
|
+
const request = expectCompleteMetadata('chat_assistant_prompt');
|
|
242
|
+
expectProductEnvelope(request.multimodalInputs[1].metadata, 'id', 'pump-1');
|
|
243
|
+
expect(request.multimodalInputs[1].image).toBe(
|
|
244
|
+
hasImages ? 'product-image' : undefined,
|
|
245
|
+
);
|
|
246
|
+
expect(useConversationStore.getState().error).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test.each([false, true])(
|
|
250
|
+
'recovery includes all metadata for thumbnail and text-only results: %s',
|
|
251
|
+
async hasImages => {
|
|
252
|
+
mockResultState.productsFromFindApi = Array.from(
|
|
253
|
+
{ length: 31 },
|
|
254
|
+
(_, index) => ({ ...mockProduct, sku: `pump-${index}` }),
|
|
255
|
+
);
|
|
256
|
+
mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
|
|
257
|
+
mockChatAnalysis.mockResolvedValue({
|
|
258
|
+
message: 'What pressure do you need?',
|
|
259
|
+
action: 'ask',
|
|
260
|
+
options: [],
|
|
261
|
+
});
|
|
262
|
+
const { result } = renderHook(() => useBadResultsRecovery());
|
|
263
|
+
await act(async () => {
|
|
264
|
+
await result.current.sendMessage('Find a pump');
|
|
265
|
+
});
|
|
266
|
+
const request = expectCompleteMetadata('recovery_prompt');
|
|
267
|
+
expectProductEnvelope(request.multimodalInputs[1].metadata, 'index', 0);
|
|
268
|
+
expect(request.multimodalInputs[1].image).toBe(
|
|
269
|
+
hasImages ? 'product-image' : undefined,
|
|
270
|
+
);
|
|
271
|
+
const remaining = request.multimodalInputs.find(input =>
|
|
272
|
+
input.metadata.startsWith('REMAINING RESULTS'),
|
|
273
|
+
);
|
|
274
|
+
expect(remaining?.metadata).toContain('"index":30');
|
|
275
|
+
expectProductEnvelope(remaining!.metadata.split('\n')[1], 'index', 30);
|
|
276
|
+
expect(remaining?.metadata).toContain(
|
|
277
|
+
JSON.stringify(mockProduct.description),
|
|
278
|
+
);
|
|
279
|
+
expect(remaining?.metadata).toContain(JSON.stringify(mockProduct.attributes));
|
|
280
|
+
expect(useConversationStore.getState().error).toBeNull();
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
test.each([false, true])(
|
|
285
|
+
'image refinement sends complete metadata with thumbnails available: %s',
|
|
286
|
+
async hasImages => {
|
|
287
|
+
mockResultState.productsFromFindApi = [mockProduct, mockProduct, mockProduct];
|
|
288
|
+
mockFetchImage.mockResolvedValue(hasImages ? 'product-image' : null);
|
|
289
|
+
mockChatAnalysis.mockResolvedValue({
|
|
290
|
+
message: 'Found a pump',
|
|
291
|
+
matched_indices: [1],
|
|
292
|
+
});
|
|
293
|
+
const { result } = renderHook(() => useChatAssistantLogic());
|
|
294
|
+
await act(async () => {
|
|
295
|
+
await result.current.handleImageUpload('data:image/jpeg;base64,new-image');
|
|
296
|
+
});
|
|
297
|
+
const request = expectCompleteMetadata('image_refinement');
|
|
298
|
+
expectProductEnvelope(request.multimodalInputs[2].metadata, 'rank', 1);
|
|
299
|
+
expect(request.multimodalInputs[2].image).toBe(
|
|
300
|
+
hasImages ? 'product-image' : undefined,
|
|
301
|
+
);
|
|
302
|
+
expect(mockResultState.setFindApiProducts).toHaveBeenCalledWith([
|
|
303
|
+
mockProduct,
|
|
304
|
+
]);
|
|
305
|
+
expect(useConversationStore.getState().error).toBeNull();
|
|
306
|
+
},
|
|
307
|
+
);
|
|
@@ -14,6 +14,11 @@ import useConversationStore, {
|
|
|
14
14
|
import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
|
|
15
15
|
import useResultStore from 'stores/result/resultStore';
|
|
16
16
|
import { chatAnalysis } from 'services/vizo';
|
|
17
|
+
import {
|
|
18
|
+
getChatAttributeFrequency,
|
|
19
|
+
getChatProductAttributes,
|
|
20
|
+
getChatProductMetadata,
|
|
21
|
+
} from 'utils/chatProductMetadata';
|
|
17
22
|
import {
|
|
18
23
|
getProductImageUrl,
|
|
19
24
|
fetchProductImageBase64,
|
|
@@ -98,29 +103,8 @@ export function useBadResultsRecovery() {
|
|
|
98
103
|
const allResults = originalProducts
|
|
99
104
|
.slice(0, 100)
|
|
100
105
|
.map((p: any, i: number) => {
|
|
101
|
-
|
|
102
|
-
const metadata
|
|
103
|
-
const metadataFields = [
|
|
104
|
-
'voltage',
|
|
105
|
-
'size',
|
|
106
|
-
'brand',
|
|
107
|
-
'manufacturer',
|
|
108
|
-
'material',
|
|
109
|
-
'type',
|
|
110
|
-
'category',
|
|
111
|
-
'color',
|
|
112
|
-
'dimensions',
|
|
113
|
-
'power',
|
|
114
|
-
'current',
|
|
115
|
-
'connector',
|
|
116
|
-
'model',
|
|
117
|
-
'series',
|
|
118
|
-
'product_line',
|
|
119
|
-
];
|
|
120
|
-
|
|
121
|
-
metadataFields.forEach(field => {
|
|
122
|
-
if (p[field]) metadata[field] = p[field];
|
|
123
|
-
});
|
|
106
|
+
const fullMetadata = getChatProductMetadata(p);
|
|
107
|
+
const metadata = getChatProductAttributes(fullMetadata);
|
|
124
108
|
|
|
125
109
|
// Extract from title/description using common patterns
|
|
126
110
|
const title = p.title || p.name || '';
|
|
@@ -172,26 +156,15 @@ export function useBadResultsRecovery() {
|
|
|
172
156
|
|
|
173
157
|
return {
|
|
174
158
|
index: i,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
description: (p.description || '').substring(0, 100),
|
|
178
|
-
category: p.category || p.type || '',
|
|
179
|
-
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
|
159
|
+
metadata: fullMetadata,
|
|
160
|
+
attributes: metadata,
|
|
180
161
|
};
|
|
181
162
|
});
|
|
182
163
|
|
|
183
164
|
// Analyze common attributes across results for smart question options
|
|
184
|
-
const attributeFrequency
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
Object.entries(r.metadata).forEach(([key, value]) => {
|
|
188
|
-
if (!attributeFrequency[key]) attributeFrequency[key] = {};
|
|
189
|
-
const strValue = String(value);
|
|
190
|
-
attributeFrequency[key][strValue] =
|
|
191
|
-
(attributeFrequency[key][strValue] || 0) + 1;
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
});
|
|
165
|
+
const attributeFrequency = getChatAttributeFrequency(
|
|
166
|
+
allResults.map(result => ({ metadata: result.attributes })),
|
|
167
|
+
);
|
|
195
168
|
|
|
196
169
|
// Build conversation history
|
|
197
170
|
const conversationHistory = messages.map(m => ({
|
|
@@ -255,8 +228,9 @@ PRODUCT LIST (first 30 with thumbnails where available, indices 0-based for filt
|
|
|
255
228
|
// Part 2: First 30 results interleaved with thumbnails
|
|
256
229
|
allResults
|
|
257
230
|
.slice(0, 30)
|
|
258
|
-
.forEach((
|
|
259
|
-
const
|
|
231
|
+
.forEach(({ index, metadata }, i: number) => {
|
|
232
|
+
const r = { index, metadata };
|
|
233
|
+
const metaLine = JSON.stringify(getChatProductMetadata(r));
|
|
260
234
|
|
|
261
235
|
if (hasVisualContext && imageResults[i]?.base64) {
|
|
262
236
|
multimodalParts.push({
|
|
@@ -276,8 +250,8 @@ PRODUCT LIST (first 30 with thumbnails where available, indices 0-based for filt
|
|
|
276
250
|
${allResults
|
|
277
251
|
.slice(30)
|
|
278
252
|
.map(
|
|
279
|
-
(
|
|
280
|
-
|
|
253
|
+
({ index, metadata }) =>
|
|
254
|
+
JSON.stringify(getChatProductMetadata({ index, metadata })),
|
|
281
255
|
)
|
|
282
256
|
.join('\n')}`,
|
|
283
257
|
});
|
|
@@ -360,12 +334,15 @@ Based on the user's answer and the failure mode strategy, decide whether to ask
|
|
|
360
334
|
}
|
|
361
335
|
|
|
362
336
|
// Add assistant message
|
|
363
|
-
addMessage(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
337
|
+
addMessage(
|
|
338
|
+
{
|
|
339
|
+
role: 'assistant',
|
|
340
|
+
content: p5Response.message,
|
|
341
|
+
action: { type: p5Response.action },
|
|
342
|
+
options: options,
|
|
343
|
+
},
|
|
344
|
+
{ isApiResponse: true },
|
|
345
|
+
);
|
|
369
346
|
} catch (err: any) {
|
|
370
347
|
console.error('[P5] Error:', err);
|
|
371
348
|
setError(err.message || 'Failed to process message');
|
|
@@ -26,12 +26,14 @@ export function useEffectiveGroundingResults() {
|
|
|
26
26
|
const postEntries = Object.entries(postFilterSelections || {}).filter(
|
|
27
27
|
([, values]) => values?.length,
|
|
28
28
|
);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
// Text-only find-API post-filters re-query; skip client-side narrowing.
|
|
30
|
+
// Image search uses the old client-side post-filter behavior.
|
|
31
|
+
const isTextOnlyFindApiPostFilter =
|
|
32
|
+
!window.settings.algolia?.enabled &&
|
|
33
|
+
window.settings.postFilterOption &&
|
|
34
|
+
!(requestImages?.length > 0);
|
|
35
|
+
|
|
36
|
+
if (postEntries.length > 0 && !isTextOnlyFindApiPostFilter) {
|
|
35
37
|
filteredProducts = filteredProducts.filter((product: any) => {
|
|
36
38
|
const productFilters = product?.filters || {};
|
|
37
39
|
return postEntries.every(([attribute, selectedValues]) => {
|