From d75064c4d819b4f61a9f4bd02cc529eda3823e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20S=C5=82omowski?= Date: Sat, 7 Feb 2026 11:18:20 +0100 Subject: [PATCH] feat: Add product details agent for single product queries Implement a dedicated productNode to handle product detail queries: - Support position-based references ("the first one", "#2") - Support name-based references ("Gaming Laptop Pro X1") - Add IProductAttribute interface for structured specifications - Track lastSearchResults in graph state between conversation turns - Extract specs from description as fallback when attributes missing Changes: - domain/product.ts: Add IProductAttribute and attributes field - agents/graph/state.ts: Add ISearchResult and lastSearchResults - agents/graph/nodes/productNode.ts: New node for product details - agents/prompts/productPrompts.ts: LLM prompts for reference extraction - models/products/productsModel.ts: Add findProductByName function - Updated routing, translations, and tests Co-Authored-By: Claude Opus 4.5 --- .../evaluation/conversationRunner.ts | 15 +- agents/__tests__/evaluation/evaluator.ts | 23 ++ .../evaluation/productDetails.e2e.test.ts | 183 +++++++++++++++ agents/__tests__/evaluation/testFixtures.ts | 54 +++++ agents/graph/chatGraph.test.ts | 55 +++-- agents/graph/chatGraph.ts | 25 +- agents/graph/edges.test.ts | 17 +- agents/graph/edges.ts | 5 +- agents/graph/nodes/productNode.test.ts | 217 ++++++++++++++++++ agents/graph/nodes/productNode.ts | 81 +++++++ agents/graph/nodes/productsNode.ts | 10 +- agents/graph/state.ts | 8 + agents/prompts/productPrompts.test.ts | 76 ++++++ agents/prompts/productPrompts.ts | 79 +++++++ domain/product.ts | 8 + messages/en.json | 13 ++ messages/pl.json | 13 ++ models/products/productsModel.ts | 22 ++ services/chat/chat.service.ts | 6 +- .../product/productDetailsService.test.ts | 91 ++++++++ services/product/productDetailsService.ts | 165 +++++++++++++ 21 files changed, 1125 insertions(+), 41 deletions(-) create mode 100644 agents/__tests__/evaluation/productDetails.e2e.test.ts create mode 100644 agents/graph/nodes/productNode.test.ts create mode 100644 agents/graph/nodes/productNode.ts create mode 100644 agents/prompts/productPrompts.test.ts create mode 100644 agents/prompts/productPrompts.ts create mode 100644 services/product/productDetailsService.test.ts create mode 100644 services/product/productDetailsService.ts diff --git a/agents/__tests__/evaluation/conversationRunner.ts b/agents/__tests__/evaluation/conversationRunner.ts index 8d8b0c3..6f4c693 100644 --- a/agents/__tests__/evaluation/conversationRunner.ts +++ b/agents/__tests__/evaluation/conversationRunner.ts @@ -1,4 +1,5 @@ import { executeChatGraphWithStream, IStreamCallback } from '@/agents/graph/chatGraph'; +import { ISearchResult } from '@/agents/graph/state'; import { IConversationTurn } from './evaluator'; export interface IConversationScenario { @@ -32,22 +33,26 @@ export const runConversation = async ( const callbacks = createNoopCallbacks(); const messages: Array<{ role: string; content: string }> = []; + let lastSearchResults: ISearchResult | null = null; for (const turn of scenario.turns) { messages.push({ role: 'user', content: turn.userMessage }); conversation.push({ role: 'user', content: turn.userMessage }); - const response = await executeChatGraphWithStream( + const result = await executeChatGraphWithStream( sessionId, scenario.locale, messages, - callbacks + callbacks, + lastSearchResults ); - messages.push({ role: 'assistant', content: response }); - conversation.push({ role: 'assistant', content: response }); + lastSearchResults = result.lastSearchResults; - if (turn.validateResponse && !turn.validateResponse(response)) { + messages.push({ role: 'assistant', content: result.response }); + conversation.push({ role: 'assistant', content: result.response }); + + if (turn.validateResponse && !turn.validateResponse(result.response)) { return { scenario, conversation, diff --git a/agents/__tests__/evaluation/evaluator.ts b/agents/__tests__/evaluation/evaluator.ts index 2685fa6..f703c50 100644 --- a/agents/__tests__/evaluation/evaluator.ts +++ b/agents/__tests__/evaluation/evaluator.ts @@ -164,3 +164,26 @@ export const defaultChatCriteria: IEvaluationCriteria[] = [ weight: 1, }, ]; + +export const defaultProductDetailsCriteria: IEvaluationCriteria[] = [ + { + name: 'Accuracy', + description: 'Does the assistant provide accurate product details?', + weight: 3, + }, + { + name: 'Completeness', + description: 'Does the response include relevant specifications?', + weight: 2, + }, + { + name: 'Reference Understanding', + description: 'Does the assistant correctly identify which product the user is asking about?', + weight: 3, + }, + { + name: 'Natural Language', + description: 'Is the response natural and easy to understand?', + weight: 1, + }, +]; diff --git a/agents/__tests__/evaluation/productDetails.e2e.test.ts b/agents/__tests__/evaluation/productDetails.e2e.test.ts new file mode 100644 index 0000000..c8804e8 --- /dev/null +++ b/agents/__tests__/evaluation/productDetails.e2e.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + evaluateConversation, + defaultProductDetailsCriteria, + IEvaluationResult, + IConversationTurn, +} from './evaluator'; +import { runConversation, IConversationScenario } from './conversationRunner'; +import { clearLastRunDirectory, saveFailedTest } from './testResultsReporter'; +import { setupTestProducts, teardownTestProducts } from './testFixtures'; + +const MINIMUM_PASSING_SCORE = 3.5; + +beforeAll(async () => { + clearLastRunDirectory(); + await setupTestProducts(); +}, 60000); + +afterAll(async () => { + await teardownTestProducts(); +}, 30000); + +const productDetailsScenarios: IConversationScenario[] = [ + { + name: 'Product details by position', + locale: 'en', + turns: [ + { userMessage: 'Show me laptops' }, + { userMessage: 'What are the specs of the first one?' }, + ], + expectedBehavior: + 'After showing laptops, the assistant should provide detailed specifications of the first laptop including RAM, processor, storage from attributes or description.', + }, + { + name: 'Product details by name', + locale: 'en', + turns: [{ userMessage: 'Tell me about Gaming Laptop Pro X1' }], + expectedBehavior: + 'The assistant should provide detailed information about the Gaming Laptop Pro X1 including specifications like RAM, GPU, and storage.', + }, + { + name: 'Product details in Polish', + locale: 'pl', + turns: [ + { userMessage: 'Pokaż laptopy' }, + { userMessage: 'Jaki procesor ma pierwszy?' }, + ], + expectedBehavior: + 'The assistant should provide processor details of the first laptop in Polish language.', + }, + { + name: 'Non-existent product', + locale: 'en', + turns: [{ userMessage: 'Tell me about SuperPhone 3000' }], + expectedBehavior: + 'The assistant should indicate that the product was not found or ask for more information.', + }, + { + name: 'Product details by partial name', + locale: 'en', + turns: [{ userMessage: 'What specs does the iPhone have?' }], + expectedBehavior: + 'The assistant should provide details about the iPhone 15 Pro Max including processor and storage.', + }, +]; + +describe('Product Details E2E Evaluation', () => { + describe.each(productDetailsScenarios)('Scenario: $name', (scenario) => { + let evaluationResult: IEvaluationResult; + let conversation: IConversationTurn[]; + + beforeAll(async () => { + const conversationResult = await runConversation(scenario); + conversation = conversationResult.conversation; + + console.log(`\n=== Conversation: ${scenario.name} ===`); + conversation.forEach((turn) => { + console.log(`${turn.role.toUpperCase()}: ${turn.content}`); + }); + + expect(conversationResult.success).toBe(true); + + evaluationResult = await evaluateConversation( + conversation, + defaultProductDetailsCriteria, + scenario.expectedBehavior + ); + + console.log(`\nEvaluation Score: ${evaluationResult.score}`); + console.log(`Reasoning: ${evaluationResult.reasoning}\n`); + + if (evaluationResult.score < MINIMUM_PASSING_SCORE) { + saveFailedTest(scenario, conversation, evaluationResult); + } + }, 180000); + + it('should pass LLM evaluation with score >= 3.5', () => { + expect(evaluationResult.score).toBeGreaterThanOrEqual(MINIMUM_PASSING_SCORE); + expect(evaluationResult.passed).toBe(true); + }); + + it('should have valid reasoning', () => { + expect(evaluationResult.reasoning).toBeTruthy(); + expect(evaluationResult.reasoning.length).toBeGreaterThan(10); + }); + }); +}); + +const MULTI_TURN_COMPLEX_MIN_SCORE = 3.0; + +const multiTurnDetailsScenarios: Array<{ + scenario: IConversationScenario; + minScore: number; +}> = [ + { + scenario: { + name: 'Search then ask for multiple products', + locale: 'en', + turns: [ + { userMessage: 'Show me smartphones' }, + { userMessage: 'Tell me more about the first one' }, + { userMessage: 'What about the second one?' }, + ], + expectedBehavior: + 'The assistant should show smartphones first, then provide details for the first smartphone, then provide details for the second smartphone. Each product should have specifications.', + }, + minScore: MULTI_TURN_COMPLEX_MIN_SCORE, + }, + { + scenario: { + name: 'Search then compare', + locale: 'en', + turns: [ + { userMessage: 'I need a laptop' }, + { userMessage: 'How much RAM does the first one have?' }, + ], + expectedBehavior: + 'The assistant should first show laptops, then provide the RAM specification for the first laptop when asked.', + }, + minScore: MINIMUM_PASSING_SCORE, + }, +]; + +describe('Multi-Turn Product Details E2E Evaluation', () => { + describe.each(multiTurnDetailsScenarios)('Scenario: $scenario.name', ({ scenario, minScore }) => { + let evaluationResult: IEvaluationResult; + let conversation: IConversationTurn[]; + + beforeAll(async () => { + const conversationResult = await runConversation(scenario); + conversation = conversationResult.conversation; + + console.log(`\n=== Multi-Turn: ${scenario.name} ===`); + conversation.forEach((turn) => { + console.log(`${turn.role.toUpperCase()}: ${turn.content}`); + }); + + expect(conversationResult.success).toBe(true); + + evaluationResult = await evaluateConversation( + conversation, + defaultProductDetailsCriteria, + scenario.expectedBehavior + ); + + console.log(`\nEvaluation Score: ${evaluationResult.score}`); + console.log(`Reasoning: ${evaluationResult.reasoning}\n`); + + if (evaluationResult.score < minScore) { + saveFailedTest(scenario, conversation, evaluationResult); + } + }, 240000); + + it(`should pass LLM evaluation with score >= ${minScore}`, () => { + expect(evaluationResult.score).toBeGreaterThanOrEqual(minScore); + }); + + it('should have valid reasoning', () => { + expect(evaluationResult.reasoning).toBeTruthy(); + expect(evaluationResult.reasoning.length).toBeGreaterThan(10); + }); + }); +}); diff --git a/agents/__tests__/evaluation/testFixtures.ts b/agents/__tests__/evaluation/testFixtures.ts index ad858d8..94b70ba 100644 --- a/agents/__tests__/evaluation/testFixtures.ts +++ b/agents/__tests__/evaluation/testFixtures.ts @@ -13,6 +13,12 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 15, category: 'Laptops', isActive: true, + attributes: [ + { name: 'RAM', value: '32', unit: 'GB' }, + { name: 'GPU', value: 'RTX 4080' }, + { name: 'Storage', value: '1', unit: 'TB SSD' }, + { name: 'Processor', value: 'Intel Core i9-13900HX' }, + ], }, { name: 'Business Laptop Elite', @@ -22,6 +28,12 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 25, category: 'Laptops', isActive: true, + attributes: [ + { name: 'RAM', value: '16', unit: 'GB' }, + { name: 'Processor', value: 'Intel Core i7-1365U' }, + { name: 'Storage', value: '512', unit: 'GB SSD' }, + { name: 'Weight', value: '1.3', unit: 'kg' }, + ], }, { name: 'Budget Laptop Basic', @@ -31,6 +43,11 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 50, category: 'Laptops', isActive: true, + attributes: [ + { name: 'RAM', value: '8', unit: 'GB' }, + { name: 'Processor', value: 'Intel Core i5-1235U' }, + { name: 'Storage', value: '256', unit: 'GB SSD' }, + ], }, { name: 'Samsung Galaxy S24 Ultra', @@ -40,6 +57,12 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 30, category: 'Smartphones', isActive: true, + attributes: [ + { name: 'RAM', value: '12', unit: 'GB' }, + { name: 'Storage', value: '512', unit: 'GB' }, + { name: 'Camera', value: '200', unit: 'MP' }, + { name: 'Display', value: '6.8', unit: 'inch' }, + ], }, { name: 'iPhone 15 Pro Max', @@ -49,6 +72,12 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 20, category: 'Smartphones', isActive: true, + attributes: [ + { name: 'Processor', value: 'A17 Pro' }, + { name: 'Storage', value: '256', unit: 'GB' }, + { name: 'Display', value: '6.7', unit: 'inch' }, + { name: 'Material', value: 'Titanium' }, + ], }, { name: 'Xiaomi 14 Pro', @@ -58,6 +87,11 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 40, category: 'Smartphones', isActive: true, + attributes: [ + { name: 'Processor', value: 'Snapdragon 8 Gen 3' }, + { name: 'Storage', value: '256', unit: 'GB' }, + { name: 'Camera', value: 'Leica' }, + ], }, { name: 'Mechanical Gaming Keyboard RGB', @@ -67,6 +101,11 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 100, category: 'Gaming Peripherals', isActive: true, + attributes: [ + { name: 'Switch Type', value: 'Cherry MX' }, + { name: 'Backlight', value: 'RGB' }, + { name: 'Keys', value: '104' }, + ], }, { name: 'Gaming Mouse Pro', @@ -76,6 +115,11 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 80, category: 'Gaming Peripherals', isActive: true, + attributes: [ + { name: 'DPI', value: '25000' }, + { name: 'Buttons', value: '8' }, + { name: 'Lighting', value: 'RGB' }, + ], }, { name: 'Sony WH-1000XM5 Headphones', @@ -85,6 +129,11 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 35, category: 'Audio', isActive: true, + attributes: [ + { name: 'Battery Life', value: '30', unit: 'hours' }, + { name: 'Noise Cancellation', value: 'Active' }, + { name: 'Connection', value: 'Wireless Bluetooth' }, + ], }, { name: 'AirPods Pro 2', @@ -94,6 +143,11 @@ export const TEST_PRODUCTS: IProductCreateInput[] = [ stock: 45, category: 'Audio', isActive: true, + attributes: [ + { name: 'Noise Cancellation', value: 'Active' }, + { name: 'Audio', value: 'Spatial Audio' }, + { name: 'Type', value: 'Wireless Earbuds' }, + ], }, ]; diff --git a/agents/graph/chatGraph.test.ts b/agents/graph/chatGraph.test.ts index 6a779a5..001dd2c 100644 --- a/agents/graph/chatGraph.test.ts +++ b/agents/graph/chatGraph.test.ts @@ -19,6 +19,14 @@ vi.mock('@/agents/utils/translations', () => ({ outOfStock: 'Out of stock', category: 'Category', searchError: 'An error occurred while searching for products. Please try again later.', + notFound: 'I could not find that product.', + noReference: 'I am not sure which product you are asking about.', + noSearchResults: 'I do not have any previous search results.', + productDetails: 'Product Details', + price: 'Price', + specifications: 'Specifications', + description: 'Description', + error: 'An error occurred while fetching product details.', }; let result = translations[key] || key; if (params) { @@ -44,6 +52,7 @@ vi.mock('@/models/products/weaviateProductsModel', () => ({ vi.mock('@/models/products/productsModel', () => ({ getProductById: vi.fn(() => Promise.resolve(null)), + findProductByName: vi.fn(() => Promise.resolve(null)), })); const mockLlmInvoke = vi.fn(); @@ -86,7 +95,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toBe('Hello! How can I help you today?'); + expect(result.response).toBe('Hello! How can I help you today?'); expect(mockCallbacks.onToken).toHaveBeenCalledWith('Hello! How can I help you today?'); }); @@ -102,13 +111,13 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('No products found matching your query'); + expect(result.response).toContain('No products found matching your query'); }); - it('should route to products agent when router returns "product"', async () => { + it('should route to product agent when router returns "product"', async () => { mockLlmInvoke .mockResolvedValueOnce({ content: 'product' }) - .mockResolvedValueOnce({ content: 'produkt szczegóły' }); + .mockResolvedValueOnce({ content: '{"type": "unknown"}' }); const result = await executeChatGraphWithStream( 'session-123', @@ -117,7 +126,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('No products found'); + expect(result.response).toContain('not sure which product'); }); it('should handle tool call and execute weather tool', async () => { @@ -137,7 +146,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toBe('The weather in Warsaw is sunny, 22°C.'); + expect(result.response).toBe('The weather in Warsaw is sunny, 22°C.'); expect(mockLlmInvoke).toHaveBeenCalledTimes(3); }); @@ -158,7 +167,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toBe('The result of 15 times 3 is 45.'); + expect(result.response).toBe('The result of 15 times 3 is 45.'); }); it('should handle multiple tool calls in sequence', async () => { @@ -179,7 +188,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('18'); + expect(result.response).toContain('18'); }); it('should call onError callback when graph execution fails', async () => { @@ -213,7 +222,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('laptop'); + expect(result.response).toContain('laptop'); }); it('should return no query detected when LLM returns EMPTY', async () => { @@ -228,7 +237,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('No product query detected'); + expect(result.response).toContain('No product query detected'); }); it('should return no products found when Weaviate returns empty results', async () => { @@ -243,7 +252,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('No products found matching your query'); + expect(result.response).toContain('No products found matching your query'); }); it('should return formatted products when found in database', async () => { @@ -275,11 +284,11 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('Found 1 products:'); - expect(result).toContain('Gaming Laptop Pro'); - expect(result).toContain('4999.99'); - expect(result).toContain('Electronics'); - expect(result).toContain('In stock'); + expect(result.response).toContain('Found 1 products:'); + expect(result.response).toContain('Gaming Laptop Pro'); + expect(result.response).toContain('4999.99'); + expect(result.response).toContain('Electronics'); + expect(result.response).toContain('In stock'); }); it('should filter out deleted and inactive products', async () => { @@ -342,10 +351,10 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toContain('Found 1 products:'); - expect(result).toContain('Active Laptop'); - expect(result).not.toContain('Deleted Laptop'); - expect(result).not.toContain('Inactive Laptop'); + expect(result.response).toContain('Found 1 products:'); + expect(result.response).toContain('Active Laptop'); + expect(result.response).not.toContain('Deleted Laptop'); + expect(result.response).not.toContain('Inactive Laptop'); }); it('should default to chat when router returns unknown agent', async () => { @@ -360,7 +369,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toBe('I can help you with that.'); + expect(result.response).toBe('I can help you with that.'); }); it('should handle empty response from LLM', async () => { @@ -375,7 +384,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toBe(''); + expect(result.response).toBe(''); expect(mockCallbacks.onToken).toHaveBeenCalledWith(''); }); @@ -396,7 +405,7 @@ describe('chatGraph', () => { mockCallbacks ); - expect(result).toBe('Sorry, I could not process that request.'); + expect(result.response).toBe('Sorry, I could not process that request.'); }); }); }); diff --git a/agents/graph/chatGraph.ts b/agents/graph/chatGraph.ts index 4af13a1..a2ba845 100644 --- a/agents/graph/chatGraph.ts +++ b/agents/graph/chatGraph.ts @@ -1,11 +1,12 @@ import { StateGraph, END, START } from '@langchain/langgraph'; import { HumanMessage, AIMessage, BaseMessage } from '@langchain/core/messages'; import { graphLogger } from '@/services/logger/graphLogger'; -import { GraphState } from '@/agents/graph/state'; +import { GraphState, ISearchResult } from '@/agents/graph/state'; import { routerNode } from '@/agents/graph/nodes/routerNode'; import { chatNode } from '@/agents/graph/nodes/chatNode'; import { toolsNode } from '@/agents/graph/nodes/toolsNode'; import { productsNode } from '@/agents/graph/nodes/productsNode'; +import { productNode } from '@/agents/graph/nodes/productNode'; import { finalNode } from '@/agents/graph/nodes/finalNode'; import { routeAfterRouter, shouldContinue } from '@/agents/graph/edges'; @@ -15,18 +16,25 @@ export interface IStreamCallback { onError: (error: Error) => void; } +export interface IChatGraphResult { + response: string; + lastSearchResults: ISearchResult | null; +} + const workflow = new StateGraph(GraphState) .addNode('router', routerNode) .addNode('chat', chatNode) .addNode('tools', toolsNode) .addNode('final', finalNode) .addNode('products', productsNode) + .addNode('product', productNode) .addEdge(START, 'router') - .addConditionalEdges('router', routeAfterRouter, ['chat', 'products']) + .addConditionalEdges('router', routeAfterRouter, ['chat', 'products', 'product']) .addConditionalEdges('chat', shouldContinue, { tools: 'tools', end: 'final' }) .addEdge('tools', 'chat') .addEdge('final', END) - .addEdge('products', END); + .addEdge('products', END) + .addEdge('product', END); export const chatGraph = workflow.compile(); @@ -34,8 +42,9 @@ export const executeChatGraphWithStream = async ( sessionId: string, locale: string, messages: Array<{ role: string; content: string }>, - callbacks: IStreamCallback -): Promise => { + callbacks: IStreamCallback, + previousSearchResults?: ISearchResult | null +): Promise => { graphLogger.info('graph', `Started session=${sessionId.slice(0, 8)}`); const baseMessages: BaseMessage[] = messages.map((msg) => { @@ -50,6 +59,7 @@ export const executeChatGraphWithStream = async ( locale, currentAgent: '', response: '', + lastSearchResults: previousSearchResults || null, }; try { @@ -59,7 +69,10 @@ export const executeChatGraphWithStream = async ( callbacks.onToken(response); graphLogger.info('graph', `Completed, response length=${response.length}`); - return response; + return { + response, + lastSearchResults: finalState.lastSearchResults || null, + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Graph execution failed'; callbacks.onError(new Error(errorMessage)); diff --git a/agents/graph/edges.test.ts b/agents/graph/edges.test.ts index 0255a0f..c4b692c 100644 --- a/agents/graph/edges.test.ts +++ b/agents/graph/edges.test.ts @@ -19,6 +19,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'products', response: '', + lastSearchResults: null, }; const result = routeAfterRouter(state); @@ -26,17 +27,18 @@ describe('edges', () => { expect(result).toBe('products'); }); - it('should route to products when currentAgent is "product"', () => { + it('should route to product when currentAgent is "product"', () => { const state: IGraphState = { messages: [], locale: 'en', currentAgent: 'product', response: '', + lastSearchResults: null, }; const result = routeAfterRouter(state); - expect(result).toBe('products'); + expect(result).toBe('product'); }); it('should route to chat when currentAgent is "chat"', () => { @@ -45,6 +47,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = routeAfterRouter(state); @@ -58,6 +61,7 @@ describe('edges', () => { locale: 'en', currentAgent: '', response: '', + lastSearchResults: null, }; const result = routeAfterRouter(state); @@ -71,6 +75,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'unknown', response: '', + lastSearchResults: null, }; const result = routeAfterRouter(state); @@ -88,6 +93,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -106,6 +112,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -121,6 +128,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -134,6 +142,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -150,6 +159,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -165,6 +175,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -180,6 +191,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); @@ -195,6 +207,7 @@ describe('edges', () => { locale: 'en', currentAgent: 'chat', response: '', + lastSearchResults: null, }; const result = shouldContinue(state); diff --git a/agents/graph/edges.ts b/agents/graph/edges.ts index b3c9c84..59e2581 100644 --- a/agents/graph/edges.ts +++ b/agents/graph/edges.ts @@ -4,9 +4,12 @@ import { parseToolCalls } from '@/agents/utils/toolParser'; export const routeAfterRouter = (state: IGraphState) => { const agent = state.currentAgent; - if (agent === 'products' || agent === 'product') { + if (agent === 'products') { return 'products'; } + if (agent === 'product') { + return 'product'; + } return 'chat'; }; diff --git a/agents/graph/nodes/productNode.test.ts b/agents/graph/nodes/productNode.test.ts new file mode 100644 index 0000000..5ea89c9 --- /dev/null +++ b/agents/graph/nodes/productNode.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HumanMessage } from '@langchain/core/messages'; +import { productNode } from './productNode'; +import { IGraphState } from '@/agents/graph/state'; +import { IProduct } from '@/domain/product'; + +vi.mock('@/services/logger/graphLogger', () => ({ + graphLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('@/services/llm/llm.service', () => ({ + createOllamaClient: vi.fn(() => ({ + invoke: vi.fn(), + })), +})); + +vi.mock('@/clients/mongodb/mongodb', () => ({ + connectToMongo: vi.fn(), +})); + +vi.mock('@/models/products/productsModel', () => ({ + findProductByName: vi.fn(), +})); + +describe('productNode', () => { + const mockProduct: IProduct = { + _id: 'test-id-1', + name: 'Gaming Laptop Pro X1', + description: 'High-performance gaming laptop with RTX 4080, 32GB RAM, 1TB SSD.', + price: 4999.99, + sku: 'LAPTOP-GAMING-001', + stock: 15, + category: 'Laptops', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + deleted: false, + attributes: [ + { name: 'RAM', value: '32', unit: 'GB' }, + { name: 'GPU', value: 'RTX 4080' }, + ], + }; + + describe('productNode integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return noReference when user message is empty', async () => { + const state: IGraphState = { + messages: [], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: null, + }; + + const result = await productNode(state); + + expect(result.response).toContain('not sure which product'); + }); + + it('should return noSearchResults when position reference used without search results', async () => { + const { createOllamaClient } = await import('@/services/llm/llm.service'); + (createOllamaClient as ReturnType).mockReturnValue({ + invoke: vi.fn().mockResolvedValue({ + content: '{"type": "position", "position": 1}', + }), + }); + + const state: IGraphState = { + messages: [new HumanMessage('What about the first one?')], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: null, + }; + + const result = await productNode(state); + + expect(result.response).toContain('do not have any previous search results'); + }); + + it('should return product details for valid position reference', async () => { + const { createOllamaClient } = await import('@/services/llm/llm.service'); + (createOllamaClient as ReturnType).mockReturnValue({ + invoke: vi.fn().mockResolvedValue({ + content: '{"type": "position", "position": 1}', + }), + }); + + const state: IGraphState = { + messages: [new HumanMessage('What about the first one?')], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: { + products: [mockProduct], + query: 'laptops', + timestamp: new Date(), + }, + }; + + const result = await productNode(state); + + expect(result.response).toContain('Gaming Laptop Pro X1'); + expect(result.response).toContain('4999.99'); + }); + + it('should return notFound for invalid position', async () => { + const { createOllamaClient } = await import('@/services/llm/llm.service'); + (createOllamaClient as ReturnType).mockReturnValue({ + invoke: vi.fn().mockResolvedValue({ + content: '{"type": "position", "position": 10}', + }), + }); + + const state: IGraphState = { + messages: [new HumanMessage('What about the tenth one?')], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: { + products: [mockProduct], + query: 'laptops', + timestamp: new Date(), + }, + }; + + const result = await productNode(state); + + expect(result.response).toContain('could not find that product'); + }); + + it('should lookup product by name', async () => { + const { createOllamaClient } = await import('@/services/llm/llm.service'); + const { findProductByName } = await import('@/models/products/productsModel'); + const { connectToMongo } = await import('@/clients/mongodb/mongodb'); + + (createOllamaClient as ReturnType).mockReturnValue({ + invoke: vi.fn().mockResolvedValue({ + content: '{"type": "name", "name": "Gaming Laptop Pro X1"}', + }), + }); + + (connectToMongo as ReturnType).mockResolvedValue({}); + (findProductByName as ReturnType).mockResolvedValue(mockProduct); + + const state: IGraphState = { + messages: [new HumanMessage('Tell me about Gaming Laptop Pro X1')], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: null, + }; + + const result = await productNode(state); + + expect(result.response).toContain('Gaming Laptop Pro X1'); + expect(findProductByName).toHaveBeenCalled(); + }); + + it('should return notFound when product name not found in database', async () => { + const { createOllamaClient } = await import('@/services/llm/llm.service'); + const { findProductByName } = await import('@/models/products/productsModel'); + const { connectToMongo } = await import('@/clients/mongodb/mongodb'); + + (createOllamaClient as ReturnType).mockReturnValue({ + invoke: vi.fn().mockResolvedValue({ + content: '{"type": "name", "name": "NonExistent Product"}', + }), + }); + + (connectToMongo as ReturnType).mockResolvedValue({}); + (findProductByName as ReturnType).mockResolvedValue(null); + + const state: IGraphState = { + messages: [new HumanMessage('Tell me about NonExistent Product')], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: null, + }; + + const result = await productNode(state); + + expect(result.response).toContain('could not find that product'); + }); + + it('should return noReference when reference type is unknown', async () => { + const { createOllamaClient } = await import('@/services/llm/llm.service'); + + (createOllamaClient as ReturnType).mockReturnValue({ + invoke: vi.fn().mockResolvedValue({ + content: '{"type": "unknown"}', + }), + }); + + const state: IGraphState = { + messages: [new HumanMessage('I want to know more')], + locale: 'en', + currentAgent: 'product', + response: '', + lastSearchResults: null, + }; + + const result = await productNode(state); + + expect(result.response).toContain('not sure which product'); + }); + }); +}); + diff --git a/agents/graph/nodes/productNode.ts b/agents/graph/nodes/productNode.ts new file mode 100644 index 0000000..1079859 --- /dev/null +++ b/agents/graph/nodes/productNode.ts @@ -0,0 +1,81 @@ +import { AIMessage, BaseMessage } from '@langchain/core/messages'; +import { graphLogger } from '@/services/logger/graphLogger'; +import { IGraphState } from '@/agents/graph/state'; +import { isHumanMessage } from '@/agents/utils/messageUtils'; +import { getAgentTranslations } from '@/agents/utils/translations'; +import { + extractProductReference, + findProductByReference, + getProductWithAttributes, + formatProductDetails, + IProductTranslations, +} from '@/services/product/productDetailsService'; + +const getLastUserMessage = (messages: BaseMessage[]): string => { + for (let i = messages.length - 1; i >= 0; i--) { + if (isHumanMessage(messages[i])) { + return messages[i].content?.toString() || ''; + } + } + return ''; +}; + +const buildTranslations = (t: (key: string) => string): IProductTranslations => ({ + notFound: t('notFound'), + noReference: t('noReference'), + noSearchResults: t('noSearchResults'), + productDetails: t('productDetails'), + price: t('price'), + category: t('category'), + inStock: t('inStock'), + outOfStock: t('outOfStock'), + specifications: t('specifications'), + description: t('description'), + error: t('error'), +}); + +export const productNode = async (state: IGraphState) => { + const locale = state.locale || 'en'; + const t = getAgentTranslations(locale, 'agents.product'); + const translations = buildTranslations(t); + + graphLogger.info('product', 'Starting product details lookup'); + + try { + const userMessage = getLastUserMessage(state.messages); + if (!userMessage) { + const message = translations.noReference; + return { messages: [new AIMessage(message)], response: message }; + } + + const reference = await extractProductReference(userMessage, locale); + graphLogger.info('product', `Extracted reference: ${JSON.stringify(reference)}`); + + if (reference.type === 'unknown') { + const message = translations.noReference; + return { messages: [new AIMessage(message)], response: message }; + } + + const product = await findProductByReference(reference, state.lastSearchResults); + + if (!product) { + const message = reference.type === 'position' && (!state.lastSearchResults || state.lastSearchResults.products.length === 0) + ? translations.noSearchResults + : translations.notFound; + return { messages: [new AIMessage(message)], response: message }; + } + + graphLogger.info('product', `Found product: ${product.name}`); + + const { attributes } = await getProductWithAttributes(product, locale); + const responseMessage = formatProductDetails(product, attributes, translations); + + return { messages: [new AIMessage(responseMessage)], response: responseMessage }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + graphLogger.error('product', `Lookup failed: ${errorMsg}`); + + const message = translations.error; + return { messages: [new AIMessage(message)], response: message }; + } +}; diff --git a/agents/graph/nodes/productsNode.ts b/agents/graph/nodes/productsNode.ts index 1762e7a..c945b85 100644 --- a/agents/graph/nodes/productsNode.ts +++ b/agents/graph/nodes/productsNode.ts @@ -123,7 +123,15 @@ export const productsNode = async (state: IGraphState) => { graphLogger.info('products', `Retrieved ${products.length} products from MongoDB`); const responseMessage = formatProductsResponse(products, translations); - return { messages: [new AIMessage(responseMessage)], response: responseMessage }; + return { + messages: [new AIMessage(responseMessage)], + response: responseMessage, + lastSearchResults: { + products, + query: searchQuery, + timestamp: new Date(), + }, + }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; graphLogger.error('products', `Search failed: ${errorMsg}`); diff --git a/agents/graph/state.ts b/agents/graph/state.ts index deb08a5..eac878e 100644 --- a/agents/graph/state.ts +++ b/agents/graph/state.ts @@ -1,5 +1,12 @@ import { Annotation } from '@langchain/langgraph'; import { BaseMessage } from '@langchain/core/messages'; +import { IProduct } from '@/domain/product'; + +export interface ISearchResult { + products: IProduct[]; + query: string; + timestamp: Date; +} export const GraphState = Annotation.Root({ messages: Annotation({ @@ -8,6 +15,7 @@ export const GraphState = Annotation.Root({ locale: Annotation(), currentAgent: Annotation(), response: Annotation(), + lastSearchResults: Annotation(), }); export type IGraphState = typeof GraphState.State; diff --git a/agents/prompts/productPrompts.test.ts b/agents/prompts/productPrompts.test.ts new file mode 100644 index 0000000..63ae246 --- /dev/null +++ b/agents/prompts/productPrompts.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + createProductReferenceExtractionPrompt, + createProductDetailsPrompt, +} from './productPrompts'; + +describe('productPrompts', () => { + describe('createProductReferenceExtractionPrompt', () => { + it('should return English prompt for en locale', () => { + const prompt = createProductReferenceExtractionPrompt('en'); + + expect(prompt).toContain('product reference extractor'); + expect(prompt).toContain('position'); + expect(prompt).toContain('name'); + expect(prompt).toContain('Output ONLY valid JSON'); + }); + + it('should return Polish prompt for pl locale', () => { + const prompt = createProductReferenceExtractionPrompt('pl'); + + expect(prompt).toContain('ekstraktorem referencji'); + expect(prompt).toContain('pozycji'); + expect(prompt).toContain('nazw'); + expect(prompt).toContain('TYLKO poprawny JSON'); + }); + + it('should fallback to English for unknown locale', () => { + const prompt = createProductReferenceExtractionPrompt('fr'); + + expect(prompt).toContain('product reference extractor'); + expect(prompt).toContain('Output ONLY valid JSON'); + }); + + it('should include example output formats', () => { + const prompt = createProductReferenceExtractionPrompt('en'); + + expect(prompt).toContain('{"type": "position", "position": 1}'); + expect(prompt).toContain('{"type": "name", "name":'); + expect(prompt).toContain('{"type": "unknown"}'); + }); + }); + + describe('createProductDetailsPrompt', () => { + it('should return English prompt for en locale', () => { + const prompt = createProductDetailsPrompt('en'); + + expect(prompt).toContain('product details extractor'); + expect(prompt).toContain('RAM'); + expect(prompt).toContain('processor'); + expect(prompt).toContain('Output ONLY valid JSON array'); + }); + + it('should return Polish prompt for pl locale', () => { + const prompt = createProductDetailsPrompt('pl'); + + expect(prompt).toContain('ekstraktorem szczegółów produktu'); + expect(prompt).toContain('RAM'); + expect(prompt).toContain('procesor'); + expect(prompt).toContain('TYLKO poprawną tablicę JSON'); + }); + + it('should fallback to English for unknown locale', () => { + const prompt = createProductDetailsPrompt('de'); + + expect(prompt).toContain('product details extractor'); + expect(prompt).toContain('Output ONLY valid JSON array'); + }); + + it('should include example output format', () => { + const prompt = createProductDetailsPrompt('en'); + + expect(prompt).toContain('[{"name": "RAM", "value":'); + expect(prompt).toContain('"unit":'); + }); + }); +}); diff --git a/agents/prompts/productPrompts.ts b/agents/prompts/productPrompts.ts new file mode 100644 index 0000000..8e8a946 --- /dev/null +++ b/agents/prompts/productPrompts.ts @@ -0,0 +1,79 @@ +export const createProductReferenceExtractionPrompt = (locale: string) => { + const prompts: Record = { + en: `You are a product reference extractor. Analyze the user message and identify which product they are asking about. + +Rules: +- Extract product reference from the message +- If user refers to a position (first, second, third, #1, #2, etc.), extract as position +- If user mentions a product name or partial name, extract as name +- Output ONLY valid JSON, no other text + +Output format: +{"type": "position", "position": 1} - for positional references +{"type": "name", "name": "Product Name"} - for name references +{"type": "unknown"} - if no clear reference found + +Examples: +"What specs does the first one have?" → {"type": "position", "position": 1} +"Tell me about the second laptop" → {"type": "position", "position": 2} +"How much RAM does #3 have?" → {"type": "position", "position": 3} +"Tell me about Gaming Laptop Pro X1" → {"type": "name", "name": "Gaming Laptop Pro X1"} +"What processor does the Lenovo have?" → {"type": "name", "name": "Lenovo"} +"I want to know more" → {"type": "unknown"}`, + pl: `Jesteś ekstraktorem referencji do produktów. Przeanalizuj wiadomość użytkownika i zidentyfikuj, o który produkt pyta. + +Zasady: +- Wyciągnij referencję do produktu z wiadomości +- Jeśli użytkownik odnosi się do pozycji (pierwszy, drugi, trzeci, #1, #2, itd.), wyciągnij jako pozycję +- Jeśli użytkownik wspomina nazwę produktu lub jej część, wyciągnij jako nazwę +- Wypisz TYLKO poprawny JSON, żadnego innego tekstu + +Format wyjścia: +{"type": "position", "position": 1} - dla referencji pozycyjnych +{"type": "name", "name": "Nazwa Produktu"} - dla referencji po nazwie +{"type": "unknown"} - jeśli nie znaleziono jasnej referencji + +Przykłady: +"Jaka jest specyfikacja pierwszego?" → {"type": "position", "position": 1} +"Opowiedz mi o drugim laptopie" → {"type": "position", "position": 2} +"Ile RAM ma #3?" → {"type": "position", "position": 3} +"Opowiedz mi o Gaming Laptop Pro X1" → {"type": "name", "name": "Gaming Laptop Pro X1"} +"Jaki procesor ma Lenovo?" → {"type": "name", "name": "Lenovo"} +"Chcę wiedzieć więcej" → {"type": "unknown"}`, + }; + return prompts[locale] || prompts.en; +}; + +export const createProductDetailsPrompt = (locale: string) => { + const prompts: Record = { + en: `You are a product details extractor. Analyze the product description and extract any technical specifications mentioned. + +Rules: +- Look for specifications like RAM, processor/CPU, storage, screen size, battery, etc. +- Extract values with their units when available +- Output ONLY valid JSON array, no other text +- If no specifications found, output empty array [] + +Output format: +[{"name": "RAM", "value": "16", "unit": "GB"}, {"name": "Processor", "value": "Intel i7"}] + +Examples: +"Gaming laptop with 32GB RAM and RTX 4080" → [{"name": "RAM", "value": "32", "unit": "GB"}, {"name": "GPU", "value": "RTX 4080"}] +"Basic office chair" → []`, + pl: `Jesteś ekstraktorem szczegółów produktu. Przeanalizuj opis produktu i wyciągnij wszelkie wspomniane specyfikacje techniczne. + +Zasady: +- Szukaj specyfikacji jak RAM, procesor/CPU, pamięć, rozmiar ekranu, bateria, itd. +- Wyciągaj wartości z jednostkami gdy dostępne +- Wypisz TYLKO poprawną tablicę JSON, żadnego innego tekstu +- Jeśli nie znaleziono specyfikacji, wypisz pustą tablicę [] + +Format wyjścia: +[{"name": "RAM", "value": "16", "unit": "GB"}, {"name": "Procesor", "value": "Intel i7"}] + +Przykłady: +"Laptop gamingowy z 32GB RAM i RTX 4080" → [{"name": "RAM", "value": "32", "unit": "GB"}, {"name": "GPU", "value": "RTX 4080"}] +"Podstawowe krzesło biurowe" → []`, + }; + return prompts[locale] || prompts.en; +}; diff --git a/domain/product.ts b/domain/product.ts index 798ec5c..1ed38f7 100644 --- a/domain/product.ts +++ b/domain/product.ts @@ -1,3 +1,9 @@ +export interface IProductAttribute { + name: string; + value: string; + unit?: string; +} + export interface IProduct { _id: string; name: string; @@ -11,6 +17,7 @@ export interface IProduct { createdAt: Date; updatedAt: Date; deleted: boolean; + attributes?: IProductAttribute[]; } export interface IProductCreateInput { @@ -22,4 +29,5 @@ export interface IProductCreateInput { imageUrl?: string; category: string; isActive: boolean; + attributes?: IProductAttribute[]; } diff --git a/messages/en.json b/messages/en.json index 7b87f82..402f0ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -176,6 +176,19 @@ "outOfStock": "Out of stock", "category": "Category", "searchError": "An error occurred while searching for products. Please try again later." + }, + "product": { + "notFound": "I could not find that product. Please try searching for products first or specify the product name more precisely.", + "noReference": "I am not sure which product you are asking about. Please specify the product by name or number from the list.", + "noSearchResults": "I do not have any previous search results. Please search for products first, then ask about a specific one.", + "productDetails": "Product Details", + "price": "Price", + "category": "Category", + "inStock": "In stock", + "outOfStock": "Out of stock", + "specifications": "Specifications", + "description": "Description", + "error": "An error occurred while fetching product details. Please try again later." } } } diff --git a/messages/pl.json b/messages/pl.json index 01050b1..320aea9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -176,6 +176,19 @@ "outOfStock": "Niedostępny", "category": "Kategoria", "searchError": "Wystąpił błąd podczas wyszukiwania produktów. Spróbuj ponownie później." + }, + "product": { + "notFound": "Nie mogę znaleźć tego produktu. Spróbuj najpierw wyszukać produkty lub podaj dokładniejszą nazwę produktu.", + "noReference": "Nie jestem pewien, o który produkt pytasz. Podaj nazwę produktu lub jego numer z listy.", + "noSearchResults": "Nie mam poprzednich wyników wyszukiwania. Najpierw wyszukaj produkty, a potem zapytaj o konkretny.", + "productDetails": "Szczegóły produktu", + "price": "Cena", + "category": "Kategoria", + "inStock": "Dostępny", + "outOfStock": "Niedostępny", + "specifications": "Specyfikacja", + "description": "Opis", + "error": "Wystąpił błąd podczas pobierania szczegółów produktu. Spróbuj ponownie później." } } } diff --git a/models/products/productsModel.ts b/models/products/productsModel.ts index 3e2e64c..b4b606b 100644 --- a/models/products/productsModel.ts +++ b/models/products/productsModel.ts @@ -97,3 +97,25 @@ export const deleteProduct = async ( return false; } }; + +export const findProductByName = async ( + db: Db, + name: string +): Promise => { + const collection = db.collection(PRODUCTS_COLLECTION); + + const product = await collection.findOne({ + name: { $regex: name, $options: 'i' }, + deleted: false, + isActive: true, + }); + + if (!product) { + return null; + } + + return { + ...product, + _id: product._id.toString(), + }; +}; diff --git a/services/chat/chat.service.ts b/services/chat/chat.service.ts index ac0086b..2186cd2 100644 --- a/services/chat/chat.service.ts +++ b/services/chat/chat.service.ts @@ -46,7 +46,7 @@ export const streamChatResponse = async ( content: msg.content, })); - const assistantResponse = await executeChatGraphWithStream( + const result = await executeChatGraphWithStream( currentSessionId!, locale, agentMessages, @@ -61,13 +61,13 @@ export const streamChatResponse = async ( await addMessageToConversation(currentSessionId!, { role: 'assistant', - content: assistantResponse, + content: result.response, timestamp: new Date(), }); callbacks.onComplete(messageId, currentSessionId!); - return assistantResponse; + return result.response; } catch (error) { const errorMessage = error instanceof Error ? error.message : t('processingFailed'); diff --git a/services/product/productDetailsService.test.ts b/services/product/productDetailsService.test.ts new file mode 100644 index 0000000..c350349 --- /dev/null +++ b/services/product/productDetailsService.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { formatProductDetails, IProductTranslations } from './productDetailsService'; +import { IProduct, IProductAttribute } from '@/domain/product'; + +vi.mock('@/services/logger/graphLogger', () => ({ + graphLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe('productDetailsService', () => { + const mockProduct: IProduct = { + _id: 'test-id-1', + name: 'Gaming Laptop Pro X1', + description: 'High-performance gaming laptop with RTX 4080, 32GB RAM, 1TB SSD.', + price: 4999.99, + sku: 'LAPTOP-GAMING-001', + stock: 15, + category: 'Laptops', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + deleted: false, + attributes: [ + { name: 'RAM', value: '32', unit: 'GB' }, + { name: 'GPU', value: 'RTX 4080' }, + ], + }; + + const translations: IProductTranslations = { + notFound: 'Not found', + noReference: 'No reference', + noSearchResults: 'No search results', + productDetails: 'Product Details', + price: 'Price', + category: 'Category', + inStock: 'In stock', + outOfStock: 'Out of stock', + specifications: 'Specifications', + description: 'Description', + error: 'Error', + }; + + describe('formatProductDetails', () => { + it('should format product with attributes', () => { + const attributes: IProductAttribute[] = [ + { name: 'RAM', value: '32', unit: 'GB' }, + { name: 'GPU', value: 'RTX 4080' }, + ]; + + const result = formatProductDetails(mockProduct, attributes, translations); + + expect(result).toContain('## Gaming Laptop Pro X1'); + expect(result).toContain('**Price:** 4999.99 zł'); + expect(result).toContain('**Category:** Laptops'); + expect(result).toContain('**In stock**'); + expect(result).toContain('### Specifications'); + expect(result).toContain('- **RAM:** 32 GB'); + expect(result).toContain('- **GPU:** RTX 4080'); + expect(result).toContain('### Description'); + expect(result).toContain('High-performance gaming laptop'); + }); + + it('should format product without attributes', () => { + const result = formatProductDetails(mockProduct, [], translations); + + expect(result).toContain('## Gaming Laptop Pro X1'); + expect(result).toContain('**Price:** 4999.99 zł'); + expect(result).not.toContain('### Specifications'); + expect(result).toContain('### Description'); + }); + + it('should show out of stock for zero stock', () => { + const outOfStockProduct = { ...mockProduct, stock: 0 }; + const result = formatProductDetails(outOfStockProduct, [], translations); + + expect(result).toContain('**Out of stock**'); + }); + + it('should include all required fields', () => { + const result = formatProductDetails(mockProduct, [], translations); + + expect(result).toContain(mockProduct.name); + expect(result).toContain(mockProduct.price.toFixed(2)); + expect(result).toContain(mockProduct.category); + expect(result).toContain(mockProduct.description); + }); + }); +}); diff --git a/services/product/productDetailsService.ts b/services/product/productDetailsService.ts new file mode 100644 index 0000000..cd15cc8 --- /dev/null +++ b/services/product/productDetailsService.ts @@ -0,0 +1,165 @@ +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { graphLogger } from '@/services/logger/graphLogger'; +import { connectToMongo } from '@/clients/mongodb/mongodb'; +import { findProductByName } from '@/models/products/productsModel'; +import { createOllamaClient } from '@/services/llm/llm.service'; +import { createProductReferenceExtractionPrompt, createProductDetailsPrompt } from '@/agents/prompts/productPrompts'; +import { IProduct, IProductAttribute } from '@/domain/product'; + +const REFERENCE_EXTRACTION_TEMPERATURE = 0.1; +const REFERENCE_EXTRACTION_MAX_TOKENS = 100; +const DETAILS_EXTRACTION_TEMPERATURE = 0.1; +const DETAILS_EXTRACTION_MAX_TOKENS = 500; + +export interface IProductReference { + type: 'position' | 'name' | 'unknown'; + position?: number; + name?: string; +} + +export interface IProductTranslations { + notFound: string; + noReference: string; + noSearchResults: string; + productDetails: string; + price: string; + category: string; + inStock: string; + outOfStock: string; + specifications: string; + description: string; + error: string; +} + +export interface ISearchResults { + products: IProduct[]; +} + +export const extractProductReference = async ( + message: string, + locale: string +): Promise => { + const llm = createOllamaClient(REFERENCE_EXTRACTION_TEMPERATURE, REFERENCE_EXTRACTION_MAX_TOKENS); + const systemPrompt = createProductReferenceExtractionPrompt(locale); + + const response = await llm.invoke([ + new SystemMessage(systemPrompt), + new HumanMessage(message), + ]); + + const content = response.content.toString().trim(); + + try { + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + return { + type: parsed.type || 'unknown', + position: parsed.position, + name: parsed.name, + }; + } + } catch { + graphLogger.warn('product', `Failed to parse reference: ${content}`); + } + + return { type: 'unknown' }; +}; + +export const extractAttributesFromDescription = async ( + description: string, + locale: string +): Promise => { + const llm = createOllamaClient(DETAILS_EXTRACTION_TEMPERATURE, DETAILS_EXTRACTION_MAX_TOKENS); + const systemPrompt = createProductDetailsPrompt(locale); + + const response = await llm.invoke([ + new SystemMessage(systemPrompt), + new HumanMessage(description), + ]); + + const content = response.content.toString().trim(); + + try { + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + return parsed.map((attr: { name: string; value: string; unit?: string }) => ({ + name: attr.name, + value: attr.value, + unit: attr.unit, + })); + } + } catch { + graphLogger.warn('product', `Failed to parse attributes from description: ${content}`); + } + + return []; +}; + +export const findProductByReference = async ( + reference: IProductReference, + searchResults: ISearchResults | null +): Promise => { + if (reference.type === 'position') { + if (!searchResults || searchResults.products.length === 0) { + return null; + } + + const index = (reference.position || 1) - 1; + if (index >= 0 && index < searchResults.products.length) { + return searchResults.products[index]; + } + return null; + } + + if (reference.type === 'name' && reference.name) { + const db = await connectToMongo(); + return findProductByName(db, reference.name); + } + + return null; +}; + +export const formatProductDetails = ( + product: IProduct, + attributes: IProductAttribute[], + t: IProductTranslations +): string => { + const lines: string[] = []; + + lines.push(`## ${product.name}`); + lines.push(''); + lines.push(`**${t.price}:** ${product.price.toFixed(2)} zł`); + lines.push(`**${t.category}:** ${product.category}`); + lines.push(`**${product.stock > 0 ? t.inStock : t.outOfStock}**`); + lines.push(''); + + if (attributes.length > 0) { + lines.push(`### ${t.specifications}`); + for (const attr of attributes) { + const value = attr.unit ? `${attr.value} ${attr.unit}` : attr.value; + lines.push(`- **${attr.name}:** ${value}`); + } + lines.push(''); + } + + lines.push(`### ${t.description}`); + lines.push(product.description); + + return lines.join('\n'); +}; + +export const getProductWithAttributes = async ( + product: IProduct, + locale: string +): Promise<{ product: IProduct; attributes: IProductAttribute[] }> => { + let attributes = product.attributes || []; + + if (attributes.length === 0) { + graphLogger.info('product', 'No attributes found, extracting from description'); + attributes = await extractAttributesFromDescription(product.description, locale); + } + + return { product, attributes }; +};