@nyris/nyris-webapp 0.3.99 → 0.4.1
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 +6 -6
- package/build/index.html +1 -1
- package/build/js/settings.example.js +3 -1
- package/build/static/css/main.a7ff7a7f.css +4 -0
- package/build/static/css/main.a7ff7a7f.css.map +1 -0
- package/build/static/js/main.1fdb0ec6.js +3 -0
- package/build/static/js/{main.2339271b.js.map → main.1fdb0ec6.js.map} +1 -1
- package/package.json +3 -3
- package/public/js/settings.example.js +3 -1
- 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/Inquiry/InquiryModal.tsx +58 -26
- package/src/components/PostFilter/PostFilterComponent.tsx +22 -8
- package/src/components/SidePanel.tsx +3 -4
- package/src/components/TextSearch.tsx +12 -4
- package/src/hooks/chatProductMetadata.test.ts +307 -0
- package/src/hooks/useBadResultsRecovery.ts +26 -49
- package/src/hooks/useGoodResultsChat.ts +20 -17
- package/src/hooks/useResultEvaluator.ts +21 -55
- package/src/services/Feedback.ts +5 -2
- package/src/stores/chat/conversationStore.test.ts +133 -0
- package/src/stores/chat/conversationStore.ts +55 -14
- package/src/types.ts +1 -0
- package/src/utils/chatProductMetadata.test.ts +130 -0
- package/src/utils/chatProductMetadata.ts +93 -0
- package/src/utils/compressImage.ts +3 -3
- package/build/static/css/main.5cb72c09.css +0 -4
- package/build/static/css/main.5cb72c09.css.map +0 -1
- package/build/static/js/main.2339271b.js +0 -3
- /package/build/static/js/{main.2339271b.js.LICENSE.txt → main.1fdb0ec6.js.LICENSE.txt} +0 -0
|
@@ -14,6 +14,11 @@ import useConversationStore, {
|
|
|
14
14
|
} from 'stores/chat/conversationStore';
|
|
15
15
|
import useSmartFiltersStore from 'stores/smartFilters/smartFiltersStore';
|
|
16
16
|
import { chatAnalysis } from 'services/vizo';
|
|
17
|
+
import {
|
|
18
|
+
getChatAttributeFrequency,
|
|
19
|
+
getChatProductAttributes,
|
|
20
|
+
getChatProductMetadata,
|
|
21
|
+
} from 'utils/chatProductMetadata';
|
|
17
22
|
import useRequestStore from 'stores/request/requestStore';
|
|
18
23
|
import { canvasToBase64, cropImage } from 'utils/cropImageToBase64';
|
|
19
24
|
import {
|
|
@@ -74,29 +79,8 @@ export function useResultEvaluator() {
|
|
|
74
79
|
const resultsSummary = activeProducts
|
|
75
80
|
.slice(0, 30)
|
|
76
81
|
.map((p: any, i: number) => {
|
|
77
|
-
|
|
78
|
-
const metadata
|
|
79
|
-
const metadataFields = [
|
|
80
|
-
'voltage',
|
|
81
|
-
'size',
|
|
82
|
-
'brand',
|
|
83
|
-
'manufacturer',
|
|
84
|
-
'material',
|
|
85
|
-
'type',
|
|
86
|
-
'category',
|
|
87
|
-
'color',
|
|
88
|
-
'dimensions',
|
|
89
|
-
'power',
|
|
90
|
-
'current',
|
|
91
|
-
'connector',
|
|
92
|
-
'model',
|
|
93
|
-
'series',
|
|
94
|
-
'product_line',
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
metadataFields.forEach(field => {
|
|
98
|
-
if (p[field]) metadata[field] = p[field];
|
|
99
|
-
});
|
|
82
|
+
const fullMetadata = getChatProductMetadata(p);
|
|
83
|
+
const metadata = getChatProductAttributes(fullMetadata);
|
|
100
84
|
|
|
101
85
|
// Extract from title/description using common patterns
|
|
102
86
|
const title = p.title || p.name || '';
|
|
@@ -181,11 +165,8 @@ export function useResultEvaluator() {
|
|
|
181
165
|
|
|
182
166
|
return {
|
|
183
167
|
rank: i + 1,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
category: p.category || p.type || 'Unknown',
|
|
187
|
-
score: p.score || p.similarity || 0,
|
|
188
|
-
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
|
168
|
+
metadata: fullMetadata,
|
|
169
|
+
attributes: metadata,
|
|
189
170
|
};
|
|
190
171
|
});
|
|
191
172
|
|
|
@@ -203,17 +184,9 @@ export function useResultEvaluator() {
|
|
|
203
184
|
});
|
|
204
185
|
|
|
205
186
|
// Analyze common attributes across results for filter suggestions
|
|
206
|
-
const attributeFrequency
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
Object.entries(r.metadata).forEach(([key, value]) => {
|
|
210
|
-
if (!attributeFrequency[key]) attributeFrequency[key] = {};
|
|
211
|
-
const strValue = String(value);
|
|
212
|
-
attributeFrequency[key][strValue] =
|
|
213
|
-
(attributeFrequency[key][strValue] || 0) + 1;
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
});
|
|
187
|
+
const attributeFrequency = getChatAttributeFrequency(
|
|
188
|
+
resultsSummary.map(result => ({ metadata: result.attributes })),
|
|
189
|
+
);
|
|
217
190
|
|
|
218
191
|
const requestImage = useRequestStore.getState().requestImages;
|
|
219
192
|
const regions = useRequestStore.getState().regions;
|
|
@@ -232,20 +205,16 @@ IMAGE DESCRIPTION: ${hasRequestImage ? imageDescription : 'N/A'}
|
|
|
232
205
|
SPECIFICATION (from image analysis):
|
|
233
206
|
${hasRequestImage ? JSON.stringify(p4Specification, null, 2) : 'N/A'}
|
|
234
207
|
|
|
235
|
-
TOP 20 RESULTS WITH METADATA:
|
|
236
|
-
${resultsSummary
|
|
237
|
-
.map(
|
|
238
|
-
r =>
|
|
239
|
-
`${r.rank}. ${r.title}${r.description ? ` - ${r.description}` : ''} (Score: ${r.score})${r.metadata ? `\n Metadata: ${JSON.stringify(r.metadata)}` : ''}`,
|
|
240
|
-
)
|
|
241
|
-
.join('\n')}
|
|
242
208
|
|
|
243
209
|
COMMON ATTRIBUTES FOUND IN RESULTS (for filter suggestions):
|
|
244
210
|
${JSON.stringify(attributeFrequency, null, 2)}
|
|
245
211
|
|
|
246
212
|
TOTAL RESULTS: ${resultsSummary.length}
|
|
247
213
|
|
|
248
|
-
Evaluate if these results match ${evaluationTarget}. If they do (same type of product), route to P3 and suggest filter options based on the COMMON ATTRIBUTES found above (like voltage, size, brand). Do NOT suggest the obvious category match
|
|
214
|
+
Evaluate if these results match ${evaluationTarget}. If they do (same type of product), route to P3 and suggest filter options based on the COMMON ATTRIBUTES found above (like voltage, size, brand). Do NOT suggest the obvious category match.
|
|
215
|
+
|
|
216
|
+
${hasRequestImage ? 'The request image is available, use it for evaluation.' : 'The request image is not available, rely solely on the user query for evaluation. Rest of the image information is only result/response images.'}.
|
|
217
|
+
`;
|
|
249
218
|
|
|
250
219
|
const productsForImages = activeProducts.slice(0, 30);
|
|
251
220
|
const imageResults = await Promise.all(
|
|
@@ -268,14 +237,9 @@ Evaluate if these results match ${evaluationTarget}. If they do (same type of pr
|
|
|
268
237
|
mimeType?: string;
|
|
269
238
|
}> = [];
|
|
270
239
|
|
|
271
|
-
resultsSummary.forEach((
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
const tagStr = tags
|
|
275
|
-
? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
|
|
276
|
-
: '';
|
|
277
|
-
|
|
278
|
-
const metaLine = `${result.rank}. ${result.title}${result.description ? ` - ${result.description}` : ''} (Score: ${result.score})${result.metadata ? ` | Metadata: ${JSON.stringify(result.metadata)}` : ''}${tagStr}`;
|
|
240
|
+
resultsSummary.forEach(({ rank, metadata }, i) => {
|
|
241
|
+
const result = { rank, metadata };
|
|
242
|
+
const metaLine = `${JSON.stringify(getChatProductMetadata(result))}. Result_images.`;
|
|
279
243
|
|
|
280
244
|
if (hasVisualContext && imageResults[i]?.base64) {
|
|
281
245
|
productImageParts.push({
|
|
@@ -283,6 +247,8 @@ Evaluate if these results match ${evaluationTarget}. If they do (same type of pr
|
|
|
283
247
|
image: imageResults[i].base64 || '',
|
|
284
248
|
mimeType: 'image/jpeg',
|
|
285
249
|
});
|
|
250
|
+
} else {
|
|
251
|
+
productImageParts.push({ metadata: metaLine });
|
|
286
252
|
}
|
|
287
253
|
});
|
|
288
254
|
|
package/src/services/Feedback.ts
CHANGED
|
@@ -6,6 +6,7 @@ import NyrisAPI, {
|
|
|
6
6
|
} from '@nyris/nyris-api';
|
|
7
7
|
import { getNyrisSettings } from 'utils/nyrisApiKey';
|
|
8
8
|
import useResultStore from 'stores/result/resultStore';
|
|
9
|
+
import useConversationStore from 'stores/chat/conversationStore';
|
|
9
10
|
import { CONVERSION_SUBTYPE, normalizeSubtype } from './feedbackSubtypes';
|
|
10
11
|
|
|
11
12
|
export interface FeedbackIds {
|
|
@@ -164,8 +165,9 @@ export const trackResultFeedback = (
|
|
|
164
165
|
success: boolean,
|
|
165
166
|
comment?: string,
|
|
166
167
|
ids?: FeedbackIds,
|
|
167
|
-
) =>
|
|
168
|
-
|
|
168
|
+
) => {
|
|
169
|
+
useConversationStore.getState().setSearchResolved(success);
|
|
170
|
+
return trackFeedbackEvent(
|
|
169
171
|
{
|
|
170
172
|
event: 'feedback',
|
|
171
173
|
data: {
|
|
@@ -175,6 +177,7 @@ export const trackResultFeedback = (
|
|
|
175
177
|
},
|
|
176
178
|
ids,
|
|
177
179
|
);
|
|
180
|
+
};
|
|
178
181
|
|
|
179
182
|
/** `region` event; converts x1/y1/x2/y2 to the API's x/y/w/h rectangle. */
|
|
180
183
|
export const trackRegion = (
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import useConversationStore from './conversationStore';
|
|
2
|
+
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
window.settings = {
|
|
5
|
+
vizo: { requestSupport: true },
|
|
6
|
+
support: { emailInquiry: true, emailTemplateId: 'support-template' },
|
|
7
|
+
} as typeof window.settings;
|
|
8
|
+
useConversationStore.getState().reset();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const sendUserMessages = (count: number) => {
|
|
12
|
+
for (let index = 0; index < count; index += 1) {
|
|
13
|
+
useConversationStore.getState().addMessage({
|
|
14
|
+
role: 'user',
|
|
15
|
+
content: `Still searching ${index}`,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const addApiResponse = () => {
|
|
21
|
+
useConversationStore.getState().addMessage(
|
|
22
|
+
{
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
content: 'Let us keep looking.',
|
|
25
|
+
options: [{ label: 'Continue search', action_type: 'filter' }],
|
|
26
|
+
},
|
|
27
|
+
{ isApiResponse: true },
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('offers support within the third API reply, once per session', () => {
|
|
32
|
+
sendUserMessages(2);
|
|
33
|
+
addApiResponse();
|
|
34
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(false);
|
|
35
|
+
|
|
36
|
+
sendUserMessages(1);
|
|
37
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(false);
|
|
38
|
+
addApiResponse();
|
|
39
|
+
const state = useConversationStore.getState();
|
|
40
|
+
expect(state.userMessageCount).toBe(3);
|
|
41
|
+
expect(state.messages).toHaveLength(5);
|
|
42
|
+
expect(state.messages[state.messages.length - 1]).toMatchObject({
|
|
43
|
+
role: 'assistant',
|
|
44
|
+
content: 'Let us keep looking.',
|
|
45
|
+
options: [
|
|
46
|
+
{ label: 'Continue search', action_type: 'filter' },
|
|
47
|
+
{ label: 'Request support', action_type: 'request_support' },
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
sendUserMessages(2);
|
|
52
|
+
addApiResponse();
|
|
53
|
+
expect(
|
|
54
|
+
useConversationStore
|
|
55
|
+
.getState()
|
|
56
|
+
.messages.filter(message =>
|
|
57
|
+
message.options?.some(
|
|
58
|
+
option => option.action_type === 'request_support',
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('waits for a response and excludes assistant and empty user messages', () => {
|
|
65
|
+
const store = useConversationStore.getState();
|
|
66
|
+
store.addMessage({ role: 'assistant', content: 'How can I help?' });
|
|
67
|
+
store.addMessage({ role: 'user', content: ' ' });
|
|
68
|
+
expect(useConversationStore.getState().userMessageCount).toBe(0);
|
|
69
|
+
sendUserMessages(3);
|
|
70
|
+
store.setIsLoading(true);
|
|
71
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(false);
|
|
72
|
+
store.setIsLoading(false);
|
|
73
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(false);
|
|
74
|
+
store.addMessage({ role: 'assistant', content: 'Something went wrong.' });
|
|
75
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(false);
|
|
76
|
+
addApiResponse();
|
|
77
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('does not offer support after a successful outcome', () => {
|
|
81
|
+
sendUserMessages(3);
|
|
82
|
+
useConversationStore.getState().setSearchResolved(true);
|
|
83
|
+
addApiResponse();
|
|
84
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('offers support on the third reply when recovery exits without another API call', () => {
|
|
88
|
+
sendUserMessages(3);
|
|
89
|
+
useConversationStore.getState().addMessage({
|
|
90
|
+
role: 'assistant',
|
|
91
|
+
content: 'I was not able to find a match.',
|
|
92
|
+
action: { type: 'exit' },
|
|
93
|
+
});
|
|
94
|
+
const state = useConversationStore.getState();
|
|
95
|
+
expect(state.supportPromptShown).toBe(true);
|
|
96
|
+
expect(state.messages[state.messages.length - 1].options).toContainEqual({
|
|
97
|
+
label: 'Request support',
|
|
98
|
+
action_type: 'request_support',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a new session resets message counting and allows another support prompt', () => {
|
|
103
|
+
sendUserMessages(3);
|
|
104
|
+
addApiResponse();
|
|
105
|
+
useConversationStore.getState().startNewSession();
|
|
106
|
+
expect(useConversationStore.getState()).toMatchObject({
|
|
107
|
+
userMessageCount: 0,
|
|
108
|
+
supportPromptShown: false,
|
|
109
|
+
searchResolved: false,
|
|
110
|
+
});
|
|
111
|
+
sendUserMessages(3);
|
|
112
|
+
addApiResponse();
|
|
113
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('does not offer support when vizo.requestSupport is disabled', () => {
|
|
117
|
+
window.settings.vizo!.requestSupport = false;
|
|
118
|
+
sendUserMessages(3);
|
|
119
|
+
addApiResponse();
|
|
120
|
+
const state = useConversationStore.getState();
|
|
121
|
+
expect(state.supportPromptShown).toBe(false);
|
|
122
|
+
expect(state.messages[state.messages.length - 1].options).not.toContainEqual(
|
|
123
|
+
expect.objectContaining({ action_type: 'request_support' }),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('offers support even when the inquiry banner and email template are disabled', () => {
|
|
128
|
+
window.settings.support.emailInquiry = false;
|
|
129
|
+
window.settings.support.emailTemplateId = '';
|
|
130
|
+
sendUserMessages(3);
|
|
131
|
+
addApiResponse();
|
|
132
|
+
expect(useConversationStore.getState().supportPromptShown).toBe(true);
|
|
133
|
+
});
|
|
@@ -39,7 +39,8 @@ export interface QuickReplyOption {
|
|
|
39
39
|
| 'restore_previous'
|
|
40
40
|
| 'related_part'
|
|
41
41
|
| 'find_related_parts'
|
|
42
|
-
| 'restore_original'
|
|
42
|
+
| 'restore_original'
|
|
43
|
+
| 'request_support';
|
|
43
44
|
value?: string;
|
|
44
45
|
key?: string;
|
|
45
46
|
}
|
|
@@ -72,7 +73,7 @@ export interface RelatedPartSuggestion {
|
|
|
72
73
|
interface ConversationState {
|
|
73
74
|
// Session state
|
|
74
75
|
sessionId: string;
|
|
75
|
-
isEvaluated: boolean;
|
|
76
|
+
isEvaluated: boolean; // P2 has fired
|
|
76
77
|
// Routing
|
|
77
78
|
route: ConversationRoute;
|
|
78
79
|
quality: ResultQuality;
|
|
@@ -84,6 +85,9 @@ interface ConversationState {
|
|
|
84
85
|
// Conversation
|
|
85
86
|
messages: ChatMessage[];
|
|
86
87
|
activeFilters: ActiveFilter[];
|
|
88
|
+
userMessageCount: number;
|
|
89
|
+
supportPromptShown: boolean;
|
|
90
|
+
searchResolved: boolean;
|
|
87
91
|
|
|
88
92
|
// UI state
|
|
89
93
|
isOpen: boolean;
|
|
@@ -126,8 +130,12 @@ interface ConversationActions {
|
|
|
126
130
|
) => void;
|
|
127
131
|
|
|
128
132
|
// Messages
|
|
129
|
-
addMessage: (
|
|
133
|
+
addMessage: (
|
|
134
|
+
message: Omit<ChatMessage, 'id' | 'timestamp'>,
|
|
135
|
+
options?: { isApiResponse?: boolean },
|
|
136
|
+
) => void;
|
|
130
137
|
clearMessages: () => void;
|
|
138
|
+
setSearchResolved: (resolved: boolean) => void;
|
|
131
139
|
|
|
132
140
|
// Filters (for P3)
|
|
133
141
|
addFilter: (key: string, value: string) => void;
|
|
@@ -172,6 +180,9 @@ const initialState: ConversationState = {
|
|
|
172
180
|
attemptCounter: 0,
|
|
173
181
|
messages: [],
|
|
174
182
|
activeFilters: [],
|
|
183
|
+
userMessageCount: 0,
|
|
184
|
+
supportPromptShown: false,
|
|
185
|
+
searchResolved: false,
|
|
175
186
|
isOpen: false, // Start closed so left panel is open by default
|
|
176
187
|
hasNotification: false,
|
|
177
188
|
isLoading: false,
|
|
@@ -240,20 +251,50 @@ const useConversationStore = create<ConversationState & ConversationActions>()(
|
|
|
240
251
|
}));
|
|
241
252
|
},
|
|
242
253
|
|
|
243
|
-
addMessage: message =>
|
|
244
|
-
set(state =>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
addMessage: (message, { isApiResponse = false } = {}) =>
|
|
255
|
+
set(state => {
|
|
256
|
+
const shouldOfferSupport =
|
|
257
|
+
window.settings?.vizo?.requestSupport &&
|
|
258
|
+
(isApiResponse || message.action?.type === 'exit') &&
|
|
259
|
+
message.role === 'assistant' &&
|
|
260
|
+
state.userMessageCount > 2 &&
|
|
261
|
+
!state.supportPromptShown &&
|
|
262
|
+
!state.searchResolved;
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
userMessageCount:
|
|
266
|
+
state.userMessageCount +
|
|
267
|
+
(message.role === 'user' &&
|
|
268
|
+
(message.content.trim() || message.imageUrl)
|
|
269
|
+
? 1
|
|
270
|
+
: 0),
|
|
271
|
+
supportPromptShown: state.supportPromptShown || shouldOfferSupport,
|
|
272
|
+
messages: [
|
|
273
|
+
...state.messages,
|
|
274
|
+
{
|
|
275
|
+
...message,
|
|
276
|
+
id: generateId(),
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
...(shouldOfferSupport
|
|
279
|
+
? {
|
|
280
|
+
options: [
|
|
281
|
+
...(message.options || []),
|
|
282
|
+
{
|
|
283
|
+
label: 'Request support',
|
|
284
|
+
action_type: 'request_support' as const,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}),
|
|
254
293
|
|
|
255
294
|
clearMessages: () => set({ messages: [] }),
|
|
256
295
|
|
|
296
|
+
setSearchResolved: searchResolved => set({ searchResolved }),
|
|
297
|
+
|
|
257
298
|
addFilter: (key, value) =>
|
|
258
299
|
set(state => ({
|
|
259
300
|
activeFilters: [
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getChatAttributeFrequency,
|
|
3
|
+
getChatProductAttributes,
|
|
4
|
+
getChatProductMetadata,
|
|
5
|
+
} from './chatProductMetadata';
|
|
6
|
+
|
|
7
|
+
test('selects only curated primitive filter traits without mutating metadata', () => {
|
|
8
|
+
const metadata = {
|
|
9
|
+
brand: 'ACME',
|
|
10
|
+
voltage: '24V DC',
|
|
11
|
+
current: 0,
|
|
12
|
+
connector: false,
|
|
13
|
+
product_type: 'PUMP',
|
|
14
|
+
sku: 'pump-1',
|
|
15
|
+
title: 'Unique pump title',
|
|
16
|
+
description: 'Unique pump description',
|
|
17
|
+
customIds: { mpn: 'MPN-1' },
|
|
18
|
+
attributes: { pressure: 10 },
|
|
19
|
+
metadata: { customAttribute: 'value' },
|
|
20
|
+
_tags: { color: 'blue' },
|
|
21
|
+
material: { name: 'steel' },
|
|
22
|
+
category: ['Pumps'],
|
|
23
|
+
size: null,
|
|
24
|
+
dimensions: undefined,
|
|
25
|
+
color: ' ',
|
|
26
|
+
power: NaN,
|
|
27
|
+
model: Infinity,
|
|
28
|
+
};
|
|
29
|
+
const snapshot = { ...metadata };
|
|
30
|
+
|
|
31
|
+
expect(getChatProductAttributes(metadata)).toEqual({
|
|
32
|
+
brand: 'ACME',
|
|
33
|
+
voltage: '24V DC',
|
|
34
|
+
current: 0,
|
|
35
|
+
connector: false,
|
|
36
|
+
product_type: 'PUMP',
|
|
37
|
+
});
|
|
38
|
+
expect(metadata).toEqual(snapshot);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('counts trait values, not full metadata or object stringifications', () => {
|
|
42
|
+
expect(
|
|
43
|
+
getChatAttributeFrequency([
|
|
44
|
+
{
|
|
45
|
+
metadata: {
|
|
46
|
+
brand: 'ACME',
|
|
47
|
+
voltage: '24V DC',
|
|
48
|
+
sku: 'pump-1',
|
|
49
|
+
attributes: { material: 'steel' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
metadata: {
|
|
54
|
+
brand: 'ACME',
|
|
55
|
+
voltage: '12V DC',
|
|
56
|
+
sku: 'pump-2',
|
|
57
|
+
material: { name: 'steel' },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{ metadata: null },
|
|
61
|
+
]),
|
|
62
|
+
).toEqual({
|
|
63
|
+
brand: { ACME: 2 },
|
|
64
|
+
voltage: { '24V DC': 1, '12V DC': 1 },
|
|
65
|
+
});
|
|
66
|
+
expect(getChatAttributeFrequency([])).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('counts trait values that match object prototype properties', () => {
|
|
70
|
+
const frequency = getChatAttributeFrequency([
|
|
71
|
+
{ metadata: { model: '__proto__', series: 'constructor' } },
|
|
72
|
+
{ metadata: { model: '__proto__', series: 'constructor' } },
|
|
73
|
+
]);
|
|
74
|
+
expect(frequency.model['__proto__']).toBe(2);
|
|
75
|
+
expect(frequency.series.constructor).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('preserves all non-URL metadata, including nested and falsy values', () => {
|
|
79
|
+
const metadata = {
|
|
80
|
+
sku: 'pump-1',
|
|
81
|
+
description: ' Complete description '.repeat(30),
|
|
82
|
+
customIds: { mpn: 'MPN-1', gtin: '123456' },
|
|
83
|
+
attributes: [
|
|
84
|
+
{ label: 'pressure', value: 0, unit: 'bar' },
|
|
85
|
+
{ label: 'available', value: false },
|
|
86
|
+
],
|
|
87
|
+
metadata: { security: 'IP67', empty: '', spaces: ' ', unknown: null },
|
|
88
|
+
_tags: { productType: 'pump', material: 'steel' },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
expect(getChatProductMetadata(metadata)).toEqual(metadata);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('removes URL fields and URL values recursively without mutating products', () => {
|
|
95
|
+
const product = {
|
|
96
|
+
sku: 'pump-1',
|
|
97
|
+
url: '/products/pump-1',
|
|
98
|
+
productURL: '/products/pump-1',
|
|
99
|
+
image_url_original: '/images/pump.jpg',
|
|
100
|
+
imageurl: '/images/pump.jpg',
|
|
101
|
+
attributes: {
|
|
102
|
+
documentationUri: '/documents/manual.pdf',
|
|
103
|
+
href: '/products/pump-1',
|
|
104
|
+
links: ['https://example.com/manual.pdf'],
|
|
105
|
+
manual: 'https://example.com/manual.pdf',
|
|
106
|
+
material: 'steel',
|
|
107
|
+
},
|
|
108
|
+
images: [
|
|
109
|
+
'https://example.com/pump.jpg',
|
|
110
|
+
{ url: '/images/pump.jpg', width: 300 },
|
|
111
|
+
],
|
|
112
|
+
references: [
|
|
113
|
+
'MPN-1',
|
|
114
|
+
'//example.com/pump',
|
|
115
|
+
'blob:https://example.com/image',
|
|
116
|
+
'data:image/png;base64,aGVsbG8=',
|
|
117
|
+
],
|
|
118
|
+
description: 'Pump details at https://example.com/pump with steel housing',
|
|
119
|
+
};
|
|
120
|
+
const snapshot = JSON.stringify(product);
|
|
121
|
+
|
|
122
|
+
expect(getChatProductMetadata(product)).toEqual({
|
|
123
|
+
sku: 'pump-1',
|
|
124
|
+
attributes: { material: 'steel' },
|
|
125
|
+
images: [{ width: 300 }],
|
|
126
|
+
references: ['MPN-1'],
|
|
127
|
+
description: 'Pump details at with steel housing',
|
|
128
|
+
});
|
|
129
|
+
expect(JSON.stringify(product)).toBe(snapshot);
|
|
130
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const urlField = /(?:^|_)(?:urls?|uris?|hrefs?|links?)(?:_|$)/i;
|
|
2
|
+
const urlValue = /(?:https?:\/\/|ftp:\/\/|\/\/|blob:|data:)[^\s"'<>]+/gi;
|
|
3
|
+
|
|
4
|
+
const attributeFields = [
|
|
5
|
+
'voltage',
|
|
6
|
+
'size',
|
|
7
|
+
'brand',
|
|
8
|
+
'manufacturer',
|
|
9
|
+
'material',
|
|
10
|
+
'type',
|
|
11
|
+
'category',
|
|
12
|
+
'color',
|
|
13
|
+
'dimensions',
|
|
14
|
+
'power',
|
|
15
|
+
'current',
|
|
16
|
+
'connector',
|
|
17
|
+
'model',
|
|
18
|
+
'series',
|
|
19
|
+
'product_line',
|
|
20
|
+
'product_type',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Keep filter traits separate from the complete product context sent to chat. */
|
|
24
|
+
export function getChatProductAttributes(
|
|
25
|
+
metadata: Record<string, unknown>,
|
|
26
|
+
): Record<string, string | number | boolean> {
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.entries(metadata).filter(
|
|
29
|
+
(entry): entry is [string, string | number | boolean] => {
|
|
30
|
+
const [key, value] = entry;
|
|
31
|
+
return (
|
|
32
|
+
attributeFields.includes(key) &&
|
|
33
|
+
((typeof value === 'string' && value.trim().length > 0) ||
|
|
34
|
+
(typeof value === 'number' && Number.isFinite(value)) ||
|
|
35
|
+
typeof value === 'boolean')
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getChatAttributeFrequency(
|
|
43
|
+
products: ReadonlyArray<{ metadata: Record<string, unknown> | null }>,
|
|
44
|
+
): Record<string, Record<string, number>> {
|
|
45
|
+
const frequency = new Map<string, Map<string, number>>();
|
|
46
|
+
products.forEach(({ metadata }) => {
|
|
47
|
+
Object.entries(getChatProductAttributes(metadata || {})).forEach(
|
|
48
|
+
([key, value]) => {
|
|
49
|
+
const counts = frequency.get(key) || new Map<string, number>();
|
|
50
|
+
const text = String(value);
|
|
51
|
+
counts.set(text, (counts.get(text) || 0) + 1);
|
|
52
|
+
frequency.set(key, counts);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
return Object.fromEntries(
|
|
57
|
+
Array.from(frequency, ([key, counts]) => [key, Object.fromEntries(counts)]),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withoutUrls(value: unknown): unknown {
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
const text = value.replace(urlValue, '');
|
|
64
|
+
return text !== value && !text.trim() ? undefined : text;
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return value.map(withoutUrls).filter(item => item !== undefined);
|
|
68
|
+
}
|
|
69
|
+
if (value !== null && typeof value === 'object') {
|
|
70
|
+
return getChatProductMetadata(value);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Preserve product metadata, including nested attributes, without URL fields or values. */
|
|
76
|
+
export function getChatProductMetadata(
|
|
77
|
+
product: object,
|
|
78
|
+
): Record<string, unknown> {
|
|
79
|
+
return Object.fromEntries(
|
|
80
|
+
Object.entries(product)
|
|
81
|
+
.filter(([key]) => {
|
|
82
|
+
const normalizedKey = key
|
|
83
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
84
|
+
.replace(/[^a-z0-9]/gi, '_');
|
|
85
|
+
return (
|
|
86
|
+
!urlField.test(normalizedKey) &&
|
|
87
|
+
!/(?:urls?|uris?|hrefs?)$/i.test(normalizedKey)
|
|
88
|
+
);
|
|
89
|
+
})
|
|
90
|
+
.map(([key, value]) => [key, withoutUrls(value)])
|
|
91
|
+
.filter(([, value]) => value !== undefined),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -28,9 +28,9 @@ export const compressImage = async (input: string | Blob): Promise<string> => {
|
|
|
28
28
|
|
|
29
29
|
return new Promise<string>((resolve, reject) => {
|
|
30
30
|
new Compressor(blob, {
|
|
31
|
-
quality:
|
|
32
|
-
maxHeight:
|
|
33
|
-
maxWidth:
|
|
31
|
+
quality: window.settings.jpegQuality,
|
|
32
|
+
maxHeight: window.settings.maxHeight,
|
|
33
|
+
maxWidth: window.settings.maxWidth,
|
|
34
34
|
strict: true,
|
|
35
35
|
success: compressedResult => {
|
|
36
36
|
const reader = new FileReader();
|