Files
baiyue-logistics-chat/logistics_chat/quote_intent.py
T

152 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Normalize customer wording into bounded quote fields without model inference."""
from decimal import Decimal
import re
from .pricing import parse_shipping_intent, detect_dim_unit, shipping_region
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))',re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*(kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅)(?![A-Za-z])',re.I)
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
def chinese_number(text):
digits={c:i for i,c in enumerate('零一二三四五六七八九')};digits.update({'两':2,'〇':0})
if all(c in digits for c in text):return ''.join(str(digits[c]) for c in text)
if '点' in text:
left,right=text.split('点',1)
return str(chinese_number(left or '零'))+'.'+''.join(str(digits[c]) for c in right)
total=section=number=0
for character in text:
if character in digits:
number=digits[character]
elif character=='万':
total+=(section+number)*10000;section=number=0
else:
section+=(number or 1)*{'十':10,'百':100,'千':1000}[character];number=0
return str(total+section+number)
def normalize(text):
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|箱|件|立方米|立方|方|kg|cbm))',
lambda m:chinese_number(m.group(1)),text,flags=re.I)
return re.sub(r'负(?=\d)','-',result)
def kg(value,unit):
value=Decimal(value)
unit=unit.lower()
if unit in ('克','g','gram','grams'):return value/1000
if unit in ('吨','ton','tons'):return value*1000
if unit in ('磅','lb','lbs'):return (value*Decimal('0.4536')).quantize(Decimal('.01'))
return value
def request_mode(text):
# Business topics outrank generic verbs such as “怎么算”.
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
if re.search(r'最便宜|便宜的|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
if re.search(r'附加费|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
if re.search(r'时效|多久|几天',text) and not WEIGHT.search(text):return 'time'
return 'quote'
def parse_intent(message,warehouse_codes):
text=normalize(message)
parsed=parse_shipping_intent(text)
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
warehouses=list(dict.fromkeys(t for t in candidates if t in warehouse_codes))
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
and not t.startswith(('USD','RMB','CNY'))]
errors=[]
weights=[m for m in WEIGHT.finditer(text) if not text[:m.start()].rstrip().endswith(('x','X','×','*'))]
weight=str(kg(*weights[0].groups())) if weights else None
boxes=re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)',text,re.I)
count=int(boxes.group(1)) if boxes else None
packages=[]
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
matches=list(package_re.finditer(text))
for index,match in enumerate(matches):
ordinal,n,value,unit=match.groups()
segment=text[match.end():matches[index+1].start() if index+1<len(matches) else len(text)]
dims=parse_shipping_intent(segment)['dims']
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
'dims':dims,'dim_unit':detect_dim_unit(segment)})
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
if each and count and not packages:
packages=[{'count':count,'weight':str(kg(*each.groups())),'dims':parsed['dims'],'dim_unit':detect_dim_unit(text)}]
if packages:
package_count=sum(p['count'] for p in packages)
aggregate_count=boxes and not any(m.start()<=boxes.start()<m.end() for m in matches)
if count and aggregate_count and count!=package_count:
errors.append('箱数与分箱明细不一致,请确认总箱数及每箱重量。')
count=package_count
weight=str(sum(Decimal(p['weight'])*p['count'] for p in packages))
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
# Per-carton dimensions must not spill onto another carton without dimensions.
if len(packages)>1:parsed['dims']=None
elif len(weights)>1:
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
if any(kg(*w.groups())<=0 for w in weights):errors.append('重量必须大于 0,请确认有效重量。')
labeled=re.search(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*([0-9-]+)',text,re.I)
postal=None
if labeled:
if re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?',labeled.group(1)):postal=labeled.group(1)[:5]
else:errors.append('美国邮编请提供5位数字,可附加4位扩展码;当前邮编格式不正确。')
else:
remaining=WEIGHT.sub(' ',text)
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
if match:postal=match.group(1)
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
if '美森' in text and not any(n.startswith('美森') for n in services):services+=['美森正班','美森加班']
kinds=[]
if re.search(r'海派|快递|UPS|FEDEX',text,re.I):kinds.append('parcel')
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路',text):kinds.append('truck')
zones=[z for z in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if z in text]
if not zones and '洛杉矶' in text:zones=['美西']
if not zones and re.search(r'纽约|萨凡纳',text):zones=['美东']
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'origin':origin,
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
'air_only':True if air and not sea else None,'air_requested':True if air else None,
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
'material':('ordinary' if re.search(r'普通货|普货|塑料|木制|非铁铝',text) else
('metal' if re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text) else None)),
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
'text':text}
def is_followup(message,intent):
if TOPIC_BOUNDARY.search(message):return False
text=message.strip().strip(',,。.!!?? ')
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
if intent.get('warehouse') or intent.get('unknown_warehouses'):
return not (intent['country'] and intent['weight'])
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin')))