Listing_SuggestPrice/backend/app/services/ai_service.ts

179 lines
7.3 KiB
TypeScript

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
}
/**
* 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<Suggestion> {
try {
return await this.openAi(input)
} catch (error) {
logger.error({ err: error }, 'OpenAI suggest lỗi — fallback rule-based')
return this.ruleBased(input)
}
}
// --- 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')
}
private static async openAi({ product, dataSources, dataEbay }: SuggestionInput): Promise<Suggestion> {
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
const payload = {
sku: product.sku,
condition: product.condition,
cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null,
supplier: dataSources,
ebaySold: dataEbay.sold,
ebaySale: dataEbay.sale,
}
const gptPayload = {
model: process.env.OPENAI_MODEL,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: this.buildSystemPrompt(floorMarkup) },
{ role: 'user', content: JSON.stringify(payload) },
],
}
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 this.normalize(remoteResp.data?.data)
}
/**
* 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',
}
}
}