343 lines
15 KiB
TypeScript
343 lines
15 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
|
|
}
|
|
|
|
/** 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<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
fn: (item: T, index: number) => Promise<R>
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(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<Suggestion> {
|
|
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<Suggestion[]> {
|
|
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<Suggestion>(inputs.length)
|
|
await mapWithConcurrency(chunks, 5, async (chunk) => {
|
|
let byId: Map<number, Suggestion>
|
|
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<Record<string, any>>): 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<any> {
|
|
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<Suggestion> {
|
|
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<Map<number, Suggestion>> {
|
|
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<number, Suggestion> {
|
|
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<number, Suggestion>()
|
|
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',
|
|
}
|
|
}
|
|
}
|