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 | 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 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( items: T[], concurrency: number, worker: (item: T) => Promise, ): Promise { 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 { type Key = string const groups = new Map() 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() 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 = { 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) 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 | 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, 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 { 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 }