import env from '#start/env' import logger from '@adonisjs/core/services/logger' import type Product from '#models/product' import type { SupplierPricePoint, EbayData } from '#models/history' import axios from 'axios' export interface SuggestionInput { product: Product dataSources: SupplierPricePoint[] dataEbay: EbayData } export interface Suggestion { suggestedPrice: number priceRange: { min: number; max: number } reasoning: string engine: 'rule' | 'ai' } const round = (n: number) => Math.round(n * 100) / 100 const median = (arr: number[]) => { if (!arr.length) return undefined const s = [...arr].sort((a, b) => a - b) const m = Math.floor(s.length / 2) return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 } /** Số sản phẩm gộp trong 1 request GPT (batch). */ export const AI_BATCH_CHUNK_SIZE = 25 /** Số điểm giá tối đa (mới nhất) gửi kèm mỗi nguồn để tiết kiệm token. */ const MAX_POINTS_PER_SOURCE = 25 /** Chạy `fn` trên `items` với concurrency giới hạn, giữ nguyên thứ tự kết quả. */ export async function mapWithConcurrency( items: T[], limit: number, fn: (item: T, index: number) => Promise ): Promise { const results = new Array(items.length) let cursor = 0 const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { while (cursor < items.length) { const index = cursor++ results[index] = await fn(items[index], index) } }) await Promise.all(workers) return results } /** * Engine gợi ý giá: dùng OpenAI khi cấu hình, ngược lại fallback rule-based * (port từ heuristic của prototype cũ). */ export default class AiService { static async suggest(input: SuggestionInput): Promise { try { return await this.openAi(input) } catch (error) { logger.error({ err: error }, 'OpenAI suggest lỗi — fallback rule-based') return this.ruleBased(input) } } /** * Gợi ý giá cho NHIỀU sản phẩm cùng lúc: gộp mỗi lô `AI_BATCH_CHUNK_SIZE` * sản phẩm vào 1 request GPT (giảm số round-trip từ N -> N/chunk), các lô * chạy song song có giới hạn. Trả về mảng cùng thứ tự với `inputs`. * * Item nào không có trong phản hồi AI (hoặc cả lô lỗi) sẽ tự động fallback * rule-based, nên kết quả luôn đủ độ dài & không rớt sản phẩm nào. */ static async suggestBatch(inputs: SuggestionInput[]): Promise { if (!inputs.length) return [] const chunks: Array<{ start: number; items: SuggestionInput[] }> = [] for (let i = 0; i < inputs.length; i += AI_BATCH_CHUNK_SIZE) { chunks.push({ start: i, items: inputs.slice(i, i + AI_BATCH_CHUNK_SIZE) }) } const results = new Array(inputs.length) await mapWithConcurrency(chunks, 5, async (chunk) => { let byId: Map try { byId = await this.openAiBatch(chunk.items) } catch (error) { logger.error({ err: error }, 'OpenAI batch suggest lỗi — fallback rule-based cả lô') byId = new Map() } chunk.items.forEach((input, idx) => { results[chunk.start + idx] = byId.get(input.product.id) ?? this.ruleBased(input) }) }) return results } // --- Rule-based --- private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion { const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) const supplierCost = (product.costs?.find((entry) => entry.currency === 'USD')?.price ?? Number(product.costs?.[0]?.price)) || dataSources.at(-1)?.price || (dataSources.length ? dataSources.reduce((a, b) => a + b.price, 0) / dataSources.length : 100) const soldMed = median(dataEbay.sold.map((x) => Number(x.price)).filter(Boolean)) const saleMed = median(dataEbay.sale.map((x) => Number(x.price)).filter(Boolean)) const anchor = soldMed ?? saleMed ?? supplierCost * 1.6 const floor = round(supplierCost * floorMarkup) const suggested = round(Math.max(anchor * 0.98, floor)) const reasoning = [ `Chi phí supplier ~$${round(supplierCost)} (condition ${product.condition}).`, soldMed != null ? `Giá đã bán eBay (median) ~$${round(soldMed)}.` : 'Chưa có dữ liệu sold.', saleMed != null ? `Giá đang bán (median) ~$${round(saleMed)}.` : 'Chưa có dữ liệu sale.', `Đề xuất $${suggested}: neo quanh thị trường, nhỉnh dưới median để dễ bán, giữ sàn $${floor}.`, ].join(' ') return { suggestedPrice: suggested, priceRange: { min: round(suggested * 0.92), max: round(suggested * 1.08) }, reasoning, engine: 'rule', } } // --- OpenAI --- /** * System prompt cho engine AI. Tách riêng để dễ tinh chỉnh & kiểm thử. * * Nguyên tắc định giá (giữ đồng bộ với rule-based để kết quả nhất quán): * - Đơn vị: USD. * - Neo giá quanh giá eBay đã bán (sold) > đang bán (sale) > chi phí supplier. * - Sàn giá = chi phí supplier * floorMarkup để luôn đảm bảo biên lợi nhuận. * - Khử nhiễu: loại các điểm giá lệch quá xa median trước khi tính. * - BẮT BUỘC trả về đúng JSON schema (không kèm markdown/giải thích ngoài JSON). */ static buildSystemPrompt(floorMarkup: number): string { return [ 'Bạn là chuyên gia định giá listing trên eBay (thị trường Úc/Mỹ).', 'Nhiệm vụ: dựa trên chi phí supplier và dữ liệu giá eBay (đã bán "sold" và đang bán "sale"),', 'đề xuất MỘT mức giá listing tối ưu bằng USD: cạnh tranh để dễ bán nhưng vẫn đảm bảo biên lợi nhuận.', '', 'Quy tắc định giá:', '1. Ưu tiên neo giá theo median giá ĐÃ BÁN (sold); nếu thiếu, dùng median giá ĐANG BÁN (sale);', ' nếu vẫn thiếu, suy ra từ chi phí supplier với biên hợp lý (~1.6x).', '2. Khử nhiễu: loại bỏ các điểm giá quá cao hoặc quá thấp bất thường so với median trước khi tính.', `3. Sàn giá tuyệt đối = chi phí supplier (USD) * ${floorMarkup}. Giá đề xuất KHÔNG được thấp hơn sàn này.`, '4. Nhỉnh dưới median thị trường một chút để tăng khả năng bán.', '5. priceRange là khoảng dao động hợp lý quanh giá đề xuất (min < suggestedPrice < max).', '', 'Chỉ trả về DUY NHẤT một object JSON hợp lệ (không markdown, không text ngoài JSON) theo schema:', '{', ' "suggestedPrice": number, // giá đề xuất (USD), > 0', ' "priceRange": { "min": number, "max": number },', ' "reasoning": string // giải thích ngắn gọn bằng tiếng Việt', '}', ].join('\n') } /** * System prompt cho engine AI ở chế độ BATCH: định giá cho một mảng sản phẩm * trong cùng một request. Giữ nguyên quy tắc định giá như bản single, chỉ khác * ở chỗ đầu vào là mảng `items` và đầu ra là mảng `results` map theo `id`. */ static buildBatchSystemPrompt(floorMarkup: number): string { return [ 'Bạn là chuyên gia định giá listing trên eBay (thị trường Úc/Mỹ).', 'Nhiệm vụ: với MỖI sản phẩm trong mảng "items" (mỗi phần tử có "id", chi phí supplier,', 'và dữ liệu giá eBay đã bán "ebaySold" / đang bán "ebaySale"), đề xuất MỘT mức giá listing', 'tối ưu bằng USD: cạnh tranh để dễ bán nhưng vẫn đảm bảo biên lợi nhuận.', '', 'Quy tắc định giá (áp dụng ĐỘC LẬP cho từng sản phẩm):', '1. Ưu tiên neo giá theo median giá ĐÃ BÁN (ebaySold); nếu thiếu, dùng median giá ĐANG BÁN (ebaySale);', ' nếu vẫn thiếu, suy ra từ chi phí supplier với biên hợp lý (~1.6x).', '2. Khử nhiễu: loại bỏ các điểm giá quá cao hoặc quá thấp bất thường so với median trước khi tính.', `3. Sàn giá tuyệt đối = chi phí supplier (USD) * ${floorMarkup}. Giá đề xuất KHÔNG được thấp hơn sàn này.`, '4. Nhỉnh dưới median thị trường một chút để tăng khả năng bán.', '5. priceRange là khoảng dao động hợp lý quanh giá đề xuất (min < suggestedPrice < max).', '', 'BẮT BUỘC trả về đủ MỘT kết quả cho MỖI id trong "items", giữ nguyên "id" tương ứng.', 'Chỉ trả về DUY NHẤT một object JSON hợp lệ (không markdown, không text ngoài JSON) theo schema:', '{', ' "results": [', ' {', ' "id": number, // đúng id của sản phẩm trong items', ' "suggestedPrice": number, // giá đề xuất (USD), > 0', ' "priceRange": { "min": number, "max": number },', ' "reasoning": string // giải thích ngắn gọn bằng tiếng Việt', ' }', ' ]', '}', ].join('\n') } /** * Rút gọn một mảng điểm giá về đúng field cần cho định giá: `{ price, date }`. * Bỏ các field thừa (title, itemId, source...), loại giá không hợp lệ, * ưu tiên các điểm mới nhất và giới hạn số lượng để tiết kiệm token. */ private static compactPoints(points?: Array>): Array<{ price: number; date?: string }> { if (!Array.isArray(points)) return [] return points .map((p) => ({ price: Number(p?.price), date: typeof p?.date === 'string' ? p.date : undefined, })) .filter((p) => Number.isFinite(p.price) && p.price > 0) .sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')) .slice(0, MAX_POINTS_PER_SOURCE) } /** Payload gọn cho 1 sản phẩm (dùng chung cho cả single & batch). */ private static buildItemPayload({ product, dataSources, dataEbay }: SuggestionInput) { return { id: product.id, sku: product.sku, condition: product.condition, cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null, supplier: this.compactPoints(dataSources), ebaySold: this.compactPoints(dataEbay?.sold), ebaySale: this.compactPoints(dataEbay?.sale), } } /** Gọi ERP proxy -> GPT, trả về data thô để normalize. */ private static async callGpt(systemPrompt: string, userPayload: unknown): Promise { const gptPayload = { model: process.env.OPENAI_MODEL, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: JSON.stringify(userPayload) }, ], } const externalApiUrl = process.env.ERP_API_URL const remoteResp = await axios.post( externalApiUrl + '/api/transferPostData', { urlAPI: '/api/open-ai-sfp/model-image-info', data: gptPayload, }, { headers: { Authorization: 'Bearer ' + process.env.ERP_API_KEY, }, } ) if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') { throw new Error('OpenAI suggest lỗi: ' + JSON.stringify(remoteResp.data)) } return remoteResp.data?.data } private static async openAi(input: SuggestionInput): Promise { const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) const { id, ...payload } = this.buildItemPayload(input) const data = await this.callGpt(this.buildSystemPrompt(floorMarkup), payload) return this.normalize(data) } /** Gọi GPT cho 1 lô sản phẩm, trả về map productId -> Suggestion. */ private static async openAiBatch(inputs: SuggestionInput[]): Promise> { const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) const payload = { items: inputs.map((input) => this.buildItemPayload(input)) } const data = await this.callGpt(this.buildBatchSystemPrompt(floorMarkup), payload) return this.normalizeBatch(data) } /** * Chuẩn hoá phản hồi batch về map `id -> Suggestion`. * AI có thể trả string JSON, mảng trực tiếp, hoặc bọc trong { results | data | result }. * Item lỗi/không hợp lệ bị bỏ qua để caller fallback rule-based từng cái. */ private static normalizeBatch(raw: any): Map { let data = raw if (typeof data === 'string') { try { data = JSON.parse(data) } catch { throw new Error('OpenAI batch trả về không phải JSON hợp lệ: ' + raw) } } const arr: any[] = Array.isArray(data) ? data : Array.isArray(data?.results) ? data.results : Array.isArray(data?.data) ? data.data : Array.isArray(data?.result) ? data.result : [] const map = new Map() for (const entry of arr) { const id = Number(entry?.id) if (!Number.isFinite(id)) continue try { map.set(id, this.normalize(entry)) } catch { // item hỏng -> để trống, caller sẽ fallback rule-based } } return map } /** * Chuẩn hoá kết quả AI về đúng shape `Suggestion`. * AI có thể trả về string JSON, object lồng trong nhiều dạng key khác nhau, * hoặc thiếu priceRange — ta coerce an toàn để downstream luôn dùng được. */ private static normalize(raw: any): Suggestion { let data = raw if (typeof data === 'string') { try { data = JSON.parse(data) } catch { throw new Error('OpenAI trả về không phải JSON hợp lệ: ' + raw) } } // một số API bọc kết quả trong { data } hoặc { result } data = data?.suggestedPrice != null ? data : (data?.data ?? data?.result ?? data) const suggestedPrice = round(Number(data?.suggestedPrice)) if (!Number.isFinite(suggestedPrice) || suggestedPrice <= 0) { throw new Error('OpenAI không trả về suggestedPrice hợp lệ: ' + JSON.stringify(raw)) } const min = Number(data?.priceRange?.min) const max = Number(data?.priceRange?.max) return { suggestedPrice, priceRange: { min: Number.isFinite(min) ? round(min) : round(suggestedPrice * 0.92), max: Number.isFinite(max) ? round(max) : round(suggestedPrice * 1.08), }, reasoning: typeof data?.reasoning === 'string' ? data.reasoning : '', engine: 'ai', } } }