476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import { getSalesProductDetail } from '@/api/channel/salesProduct'
|
||
import { callMcpTool } from '@/api/mcp'
|
||
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs'
|
||
|
||
export type QuoteCandidate = {
|
||
rowKey: string
|
||
productId?: number | string
|
||
productCode?: string
|
||
shippingMethod: string
|
||
cargoTypeName?: string
|
||
warehouse?: string
|
||
deliveryTime?: string
|
||
totalCostNum: number
|
||
maxDeliveryDays: number
|
||
isPriceOptimal?: boolean
|
||
isTimeOptimal?: boolean
|
||
guide?: string
|
||
hasGuide?: boolean
|
||
}
|
||
|
||
/** 对照对客说明后的承运判定 */
|
||
export type ShipVerdict = 'ok' | 'caution' | 'forbid' | 'unknown'
|
||
|
||
export type RecommendAlternative = {
|
||
rowKey: string
|
||
verdict: ShipVerdict
|
||
blurb: string
|
||
}
|
||
|
||
export type RecommendResult = {
|
||
rowKey: string
|
||
reason: string
|
||
source: 'ai' | 'local' | 'mcp'
|
||
verdict: ShipVerdict
|
||
/** 对客说明中支撑判定的原文摘录 */
|
||
evidence: string[]
|
||
cargoName: string
|
||
alternatives: RecommendAlternative[]
|
||
}
|
||
|
||
const FORBIDDEN_HINTS = ['禁运', '不收', '不可走', '不能走', '拒收', '禁止', '不得寄', '无法承运', '不接受']
|
||
const ALLOWED_HINTS = ['可走', '可寄', '可发', '接受', '适用', '支持', '允许', '可承运', '建议']
|
||
const CAUTION_HINTS = ['需确认', '需申报', '敏感', '限寄', '可能', '视情况', '提前沟通', '特殊要求']
|
||
|
||
const CARGO_KEYWORDS: Array<{ keys: string[]; tags: string[] }> = [
|
||
{ keys: ['电池', '带电', '锂电', '充电宝', '移动电源'], tags: ['带电', '电池', '锂电', '敏感'] },
|
||
{ keys: ['液体', '化妆', '护肤', '香水', '精油'], tags: ['液体', '化妆', '敏感'] },
|
||
{ keys: ['粉末', '奶粉', '蛋白粉'], tags: ['粉末', '敏感'] },
|
||
{ keys: ['食品', '零食', '茶叶', '保健'], tags: ['食品', '敏感'] },
|
||
{ keys: ['服装', '衣服', '鞋子', '箱包'], tags: ['普货', '服装'] },
|
||
{ keys: ['数码', '手机', '电脑', '耳机'], tags: ['普货', '带电', '数码'] },
|
||
]
|
||
|
||
const MCP_GUIDE_TOOL = 'oms.channel_guide'
|
||
const MCP_GUIDE_CONCURRENCY = 6
|
||
const MCP_GUIDE_MAX_PRODUCTS = 10
|
||
|
||
function normalizeGuide(detail: Record<string, any> | undefined) {
|
||
return String(detail?.customerGuideMarkdown ?? detail?.CustomerGuideMarkdown ?? '').trim()
|
||
}
|
||
|
||
function normalizeVerdict(raw: unknown): ShipVerdict {
|
||
const v = String(raw || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
if (['ok', 'allow', 'allowed', 'pass', '可寄', '可走', '可发'].includes(v)) return 'ok'
|
||
if (['caution', 'warn', 'warning', '慎发', '需确认', '敏感'].includes(v)) return 'caution'
|
||
if (['forbid', 'ban', 'blocked', '禁运', '不可寄', '不收'].includes(v)) return 'forbid'
|
||
return 'unknown'
|
||
}
|
||
|
||
function extractEvidenceFromGuide(guide: string, productName: string, limit = 2): string[] {
|
||
const text = String(guide || '').trim()
|
||
if (!text) return []
|
||
const name = productName.trim()
|
||
const lines = text
|
||
.split(/\n+/)
|
||
.map((l) => l.replace(/^#+\s*/, '').replace(/^[-*•]\s*/, '').trim())
|
||
.filter((l) => l.length >= 4 && l.length <= 120)
|
||
|
||
const hits: string[] = []
|
||
const lowerName = name.toLowerCase()
|
||
for (const line of lines) {
|
||
const lower = line.toLowerCase()
|
||
const nameHit =
|
||
!!lowerName &&
|
||
(lower.includes(lowerName) ||
|
||
lowerName.split(/\s+/).some((w) => w.length > 1 && lower.includes(w)))
|
||
const hintHit = [...FORBIDDEN_HINTS, ...ALLOWED_HINTS, ...CAUTION_HINTS].some((h) =>
|
||
lower.includes(h.toLowerCase()),
|
||
)
|
||
if (nameHit || hintHit) hits.push(line)
|
||
if (hits.length >= limit) break
|
||
}
|
||
if (!hits.length && lines[0]) hits.push(lines[0])
|
||
return hits
|
||
}
|
||
|
||
function parseMcpGuideContent(content: string): { guide: string; hasGuide: boolean } {
|
||
try {
|
||
const parsed = JSON.parse(content) as Record<string, any>
|
||
const items = (parsed?.items ?? parsed?.Items ?? []) as any[]
|
||
const first = items[0]
|
||
if (!first) return { guide: '', hasGuide: false }
|
||
const guide = String(
|
||
first.customerGuideMarkdown ?? first.CustomerGuideMarkdown ?? '',
|
||
).trim()
|
||
const hasGuide = Boolean(first.hasGuide ?? first.HasGuide ?? !!guide)
|
||
return { guide, hasGuide }
|
||
} catch {
|
||
return { guide: '', hasGuide: false }
|
||
}
|
||
}
|
||
|
||
async function mapPool<T, R>(
|
||
items: T[],
|
||
concurrency: number,
|
||
worker: (item: T) => Promise<R>,
|
||
): Promise<R[]> {
|
||
const results: R[] = new Array(items.length)
|
||
let cursor = 0
|
||
async function run() {
|
||
while (cursor < items.length) {
|
||
const idx = cursor++
|
||
results[idx] = await worker(items[idx])
|
||
}
|
||
}
|
||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
|
||
await Promise.all(runners)
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* 优先走 MCP「查渠道对客说明」,失败再回落销售产品详情接口。
|
||
* 同一 productCode / productId 只拉一次。
|
||
*/
|
||
export async function loadGuidesForCandidates(
|
||
candidates: QuoteCandidate[],
|
||
cargoName: string,
|
||
): Promise<QuoteCandidate[]> {
|
||
type Key = string
|
||
const groups = new Map<Key, QuoteCandidate[]>()
|
||
for (const item of candidates) {
|
||
const code = String(item.productCode || '').trim()
|
||
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
|
||
const key = code ? `code:${code}` : id ? `id:${id}` : ''
|
||
if (!key) continue
|
||
const list = groups.get(key) ?? []
|
||
list.push(item)
|
||
groups.set(key, list)
|
||
}
|
||
|
||
const keys = [...groups.keys()].slice(0, MCP_GUIDE_MAX_PRODUCTS)
|
||
const guideMap = new Map<Key, { guide: string; hasGuide: boolean; via: 'mcp' | 'api' }>()
|
||
|
||
await mapPool(keys, MCP_GUIDE_CONCURRENCY, async (key) => {
|
||
const sample = groups.get(key)![0]
|
||
const productCode = String(sample.productCode || '').trim()
|
||
const productName = String(sample.shippingMethod || '').trim()
|
||
|
||
if (productCode || productName) {
|
||
try {
|
||
const args: Record<string, string> = { cargoName: cargoName.trim() }
|
||
if (productCode) args.productCode = productCode
|
||
else args.productName = productName
|
||
const res = await callMcpTool(MCP_GUIDE_TOOL, JSON.stringify(args))
|
||
if (res.success && res.content) {
|
||
const parsed = parseMcpGuideContent(res.content)
|
||
if (parsed.guide || parsed.hasGuide === false) {
|
||
guideMap.set(key, { ...parsed, via: 'mcp' })
|
||
return
|
||
}
|
||
}
|
||
} catch {
|
||
// fall through to detail API
|
||
}
|
||
}
|
||
|
||
const id = sample.productId == null || sample.productId === '' ? '' : String(sample.productId)
|
||
if (!id) {
|
||
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
|
||
return
|
||
}
|
||
try {
|
||
const detail = await getSalesProductDetail(id)
|
||
const guide = normalizeGuide(detail as Record<string, any>)
|
||
guideMap.set(key, { guide, hasGuide: !!guide, via: 'api' })
|
||
} catch {
|
||
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
|
||
}
|
||
})
|
||
|
||
return candidates.map((item) => {
|
||
const code = String(item.productCode || '').trim()
|
||
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
|
||
const key = code ? `code:${code}` : id ? `id:${id}` : ''
|
||
const hit = key ? guideMap.get(key) : undefined
|
||
return {
|
||
...item,
|
||
guide: hit?.guide || '',
|
||
hasGuide: hit?.hasGuide ?? false,
|
||
}
|
||
})
|
||
}
|
||
|
||
function isForbidden(guide: string, productName: string) {
|
||
const text = guide.toLowerCase()
|
||
const name = productName.toLowerCase()
|
||
if (!guide) return false
|
||
for (const hint of FORBIDDEN_HINTS) {
|
||
if (!text.includes(hint.toLowerCase())) continue
|
||
if (text.includes(name) || name.split(/\s+/).some((w) => w.length > 1 && text.includes(w))) {
|
||
return true
|
||
}
|
||
const hitTag = CARGO_KEYWORDS.some(
|
||
(group) =>
|
||
group.keys.some((k) => name.includes(k.toLowerCase())) &&
|
||
group.tags.some((tag) => text.includes(tag.toLowerCase())),
|
||
)
|
||
if (hitTag) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function inferLocalVerdict(item: QuoteCandidate, productName: string): ShipVerdict {
|
||
const guide = String(item.guide || '')
|
||
if (!guide) return 'unknown'
|
||
if (isForbidden(guide, productName)) return 'forbid'
|
||
const lower = guide.toLowerCase()
|
||
const name = productName.toLowerCase()
|
||
const nameInGuide =
|
||
!!name &&
|
||
(lower.includes(name) || name.split(/\s+/).some((w) => w.length > 1 && lower.includes(w)))
|
||
const cautionHit = CAUTION_HINTS.some((h) => lower.includes(h.toLowerCase()))
|
||
const allowHit = ALLOWED_HINTS.some((h) => lower.includes(h.toLowerCase()))
|
||
if (nameInGuide && cautionHit) return 'caution'
|
||
if (nameInGuide || allowHit) return 'ok'
|
||
if (cautionHit) return 'caution'
|
||
return 'unknown'
|
||
}
|
||
|
||
function scoreCandidate(item: QuoteCandidate, productName: string) {
|
||
const guide = String(item.guide || '')
|
||
const name = productName.trim()
|
||
let score = 0
|
||
const reasons: string[] = []
|
||
const verdict = inferLocalVerdict(item, name)
|
||
|
||
if (verdict === 'forbid') {
|
||
return { score: -1000, reasons: ['线路说明提示该品类可能禁运/不收'], verdict }
|
||
}
|
||
|
||
if (!guide) {
|
||
score += 5
|
||
reasons.push('暂无线路说明,仅按报价择优')
|
||
} else {
|
||
score += 20
|
||
const lowerGuide = guide.toLowerCase()
|
||
const lowerName = name.toLowerCase()
|
||
if (lowerName && lowerGuide.includes(lowerName)) {
|
||
score += 40
|
||
reasons.push('对客说明中直接提到该品名')
|
||
}
|
||
for (const hint of ALLOWED_HINTS) {
|
||
if (lowerGuide.includes(hint.toLowerCase())) {
|
||
score += 8
|
||
reasons.push(`说明含“${hint}”`)
|
||
break
|
||
}
|
||
}
|
||
for (const group of CARGO_KEYWORDS) {
|
||
const nameHit = group.keys.some((k) => lowerName.includes(k.toLowerCase()))
|
||
if (!nameHit) continue
|
||
const tagHit = group.tags.some((tag) => lowerGuide.includes(tag.toLowerCase()))
|
||
if (tagHit) {
|
||
score += 25
|
||
reasons.push(`品名与对客说明品类匹配(${group.keys[0]})`)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (verdict === 'ok') score += 12
|
||
if (verdict === 'caution') score += 2
|
||
if (verdict === 'unknown') score -= 4
|
||
|
||
if (item.isPriceOptimal) {
|
||
score += 30
|
||
reasons.push('价格最优')
|
||
}
|
||
if (item.isTimeOptimal) {
|
||
score += 16
|
||
reasons.push('时效最优')
|
||
}
|
||
if (Number.isFinite(item.totalCostNum)) {
|
||
score += Math.max(0, 20 - Math.min(20, item.totalCostNum / 50))
|
||
}
|
||
if (Number.isFinite(item.maxDeliveryDays)) {
|
||
score += Math.max(0, 10 - Math.min(10, item.maxDeliveryDays))
|
||
}
|
||
|
||
return { score, reasons, verdict }
|
||
}
|
||
|
||
export function recommendLocally(
|
||
productName: string,
|
||
candidates: QuoteCandidate[],
|
||
): RecommendResult | null {
|
||
if (!candidates.length) return null
|
||
const ranked = candidates
|
||
.map((item) => {
|
||
const scored = scoreCandidate(item, productName)
|
||
return { item, ...scored }
|
||
})
|
||
.filter((x) => x.score > -500)
|
||
.sort((a, b) => b.score - a.score || a.item.totalCostNum - b.item.totalCostNum)
|
||
|
||
const best = ranked[0]
|
||
if (!best) return null
|
||
|
||
const alternatives: RecommendAlternative[] = ranked
|
||
.slice(1, 4)
|
||
.filter((x) => x.verdict !== 'forbid')
|
||
.map((x) => ({
|
||
rowKey: x.item.rowKey,
|
||
verdict: x.verdict,
|
||
blurb: `${x.item.shippingMethod} · ¥${Number(x.item.totalCostNum || 0).toFixed(2)}`,
|
||
}))
|
||
|
||
const usedMcp = candidates.some((c) => c.hasGuide || c.guide)
|
||
return {
|
||
rowKey: best.item.rowKey,
|
||
reason: `推荐「${best.item.shippingMethod}」:${best.reasons.slice(0, 3).join(';') || '综合报价与对客说明择优'}`,
|
||
source: usedMcp ? 'mcp' : 'local',
|
||
verdict: best.verdict,
|
||
evidence: extractEvidenceFromGuide(String(best.item.guide || ''), productName),
|
||
cargoName: productName.trim(),
|
||
alternatives,
|
||
}
|
||
}
|
||
|
||
function extractJsonObject(text: string): Record<string, any> | null {
|
||
const raw = String(text || '').trim()
|
||
if (!raw) return null
|
||
try {
|
||
return JSON.parse(raw)
|
||
} catch {
|
||
const match = raw.match(/\{[\s\S]*\}/)
|
||
if (!match) return null
|
||
try {
|
||
return JSON.parse(match[0])
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildResultFromAi(
|
||
parsed: Record<string, any>,
|
||
withGuides: QuoteCandidate[],
|
||
productName: string,
|
||
local: RecommendResult | null,
|
||
): RecommendResult | null {
|
||
const rowKey = String(parsed?.rowKey ?? parsed?.RowKey ?? '').trim()
|
||
if (!rowKey || !withGuides.some((c) => c.rowKey === rowKey)) return null
|
||
|
||
const hit = withGuides.find((c) => c.rowKey === rowKey)!
|
||
const reason = String(parsed?.reason ?? parsed?.Reason ?? '').trim()
|
||
const verdict = normalizeVerdict(parsed?.verdict ?? parsed?.Verdict)
|
||
const evidenceRaw = parsed?.evidence ?? parsed?.Evidence
|
||
const evidence = Array.isArray(evidenceRaw)
|
||
? evidenceRaw.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 3)
|
||
: extractEvidenceFromGuide(String(hit.guide || ''), productName)
|
||
|
||
const altRaw = parsed?.alternatives ?? parsed?.Alternatives
|
||
let alternatives: RecommendAlternative[] = []
|
||
if (Array.isArray(altRaw)) {
|
||
alternatives = altRaw
|
||
.map((a: any) => ({
|
||
rowKey: String(a?.rowKey ?? a?.RowKey ?? '').trim(),
|
||
verdict: normalizeVerdict(a?.verdict ?? a?.Verdict),
|
||
blurb: String(a?.blurb ?? a?.Blurb ?? a?.reason ?? '').trim(),
|
||
}))
|
||
.filter((a) => a.rowKey && withGuides.some((c) => c.rowKey === a.rowKey) && a.rowKey !== rowKey)
|
||
.slice(0, 3)
|
||
}
|
||
if (!alternatives.length && local?.alternatives?.length) {
|
||
alternatives = local.alternatives.filter((a) => a.rowKey !== rowKey).slice(0, 3)
|
||
}
|
||
|
||
return {
|
||
rowKey,
|
||
reason:
|
||
reason ||
|
||
`AI 对照对客说明后推荐:${hit.shippingMethod || rowKey}`,
|
||
source: 'ai',
|
||
verdict: verdict === 'unknown' ? inferLocalVerdict(hit, productName) : verdict,
|
||
evidence: evidence.length ? evidence : extractEvidenceFromGuide(String(hit.guide || ''), productName),
|
||
cargoName: productName.trim(),
|
||
alternatives,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查价页 AI 推荐:先 MCP 拉对客说明,再让模型给出可寄判定 + 理由 + 证据 + 备选。
|
||
*/
|
||
export async function recommendWithAi(
|
||
productName: string,
|
||
candidates: QuoteCandidate[],
|
||
): Promise<RecommendResult | null> {
|
||
const withGuides = await loadGuidesForCandidates(candidates, productName)
|
||
const local = recommendLocally(productName, withGuides)
|
||
|
||
try {
|
||
const providers = await listXhsAiProviders()
|
||
const list = Array.isArray(providers) ? providers : []
|
||
const provider =
|
||
list.find((p: any) => (p.isDefault || p.IsDefault) && (p.enabled ?? p.Enabled ?? true)) ||
|
||
list.find((p: any) => p.enabled ?? p.Enabled ?? true)
|
||
const providerKey = String(provider?.providerKey ?? provider?.ProviderKey ?? '').trim()
|
||
if (!providerKey) return local
|
||
|
||
const compact = withGuides.slice(0, 12).map((item) => ({
|
||
rowKey: item.rowKey,
|
||
channel: item.shippingMethod,
|
||
productCode: item.productCode || '',
|
||
cargo: item.cargoTypeName,
|
||
warehouse: item.warehouse,
|
||
deliveryTime: item.deliveryTime,
|
||
totalCost: item.totalCostNum,
|
||
priceOptimal: !!item.isPriceOptimal,
|
||
timeOptimal: !!item.isTimeOptimal,
|
||
hasGuide: !!item.hasGuide,
|
||
guide: String(item.guide || '').slice(0, 1200),
|
||
}))
|
||
|
||
const content = await chatXhsAi({
|
||
providerKey,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: [
|
||
'你是国际物流对客顾问。输入是客户品名 + 各渠道报价与对客说明(已由 oms.channel_guide 拉取)。',
|
||
'任务:选出最适合该品名的一条渠道,并给出承运判定。',
|
||
'规则:',
|
||
'1) 对客说明明确禁运/不收 → verdict=forbid,不要选该渠道;',
|
||
'2) 说明未提及该品名 → verdict=unknown 或 caution,勿擅自说「一定可寄」;',
|
||
'3) 说明明确可走/可寄 → verdict=ok;敏感但可议 → caution;',
|
||
'4) 同等适合时优先更便宜,其次更快;',
|
||
'5) evidence 必须摘自 guide 原文短句(1~3条),面向客户可读;',
|
||
'6) alternatives 给 0~3 条备选(不同 rowKey),含简短 blurb;',
|
||
'7) reason 用对客口吻,一句说清「为什么推荐这条」;',
|
||
'只返回 JSON:{"rowKey":"...","verdict":"ok|caution|forbid|unknown","reason":"...","evidence":["..."],"alternatives":[{"rowKey":"...","verdict":"ok","blurb":"..."}]}',
|
||
].join('\n'),
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: JSON.stringify({
|
||
cargoName: productName,
|
||
candidates: compact,
|
||
}),
|
||
},
|
||
],
|
||
})
|
||
|
||
const parsed = extractJsonObject(content?.content ?? '')
|
||
if (parsed) {
|
||
const built = buildResultFromAi(parsed, withGuides, productName, local)
|
||
if (built) return built
|
||
}
|
||
} catch {
|
||
// fallback local
|
||
}
|
||
|
||
return local
|
||
}
|