269 lines
19 KiB
Python
269 lines
19 KiB
Python
"""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_UNITS = r'kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅'
|
||
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*('+WEIGHT_UNITS+r')(?![A-Za-z])',re.I)
|
||
MISSING_WEIGHT_UNIT = '请补充修改后重量的单位,例如KG或吨,我再按新重量核算。'
|
||
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
|
||
TIMING_QUESTION = re.compile(r'时效|多久|几天|什么时候.*到|哪天.*到|何时.*到')
|
||
CLAIM_QUESTION = re.compile(r'理赔|索赔|赔(?:偿|付|钱|不赔|吗|嘛|么)|补偿|费用.*谁.*(?:承担|负责)')
|
||
DIFFERENCE = re.compile(r'差价|相差|差多少|贵多少|(?<!最)便宜多少')
|
||
|
||
|
||
def timing_with_claim(message):
|
||
if not CLAIM_QUESTION.search(message):return False
|
||
for part in re.split(r'[,,。!??;;]',message):
|
||
timing=TIMING_QUESTION.search(part)
|
||
claim=CLAIM_QUESTION.search(part)
|
||
if timing and (not claim or timing.start()<claim.start()):return True
|
||
return False
|
||
|
||
|
||
def quote_question_text(message):
|
||
"""Keep shipment conditions and timing when a separate clause asks about liability."""
|
||
if not timing_with_claim(message):return message
|
||
parts=[]
|
||
for part in re.split(r'[,,。!??;;]',message):
|
||
claim=CLAIM_QUESTION.search(part)
|
||
if claim:
|
||
if not TIMING_QUESTION.search(part):continue
|
||
part=part[:claim.start()]
|
||
if re.search(r'如果|要是|万一',part) and not TIMING_QUESTION.search(part):continue
|
||
parts.append(part)
|
||
return ','.join(parts)
|
||
|
||
|
||
def resumes_quote(message):
|
||
return bool(re.search(r'(?:回到|回头|继续)(?:刚才|之前|前面|上面)(?:的)?(?:美国)?(?:海运)?(?:报价|价格|运费)',message))
|
||
|
||
|
||
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_selection(text):
|
||
fastest=bool(re.search(r'最快|快的|更快|快一?点|fastest',text,re.I))
|
||
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
|
||
cheapest=bool(re.search(r'最便宜|便宜的|便宜一?点|更便宜|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I))
|
||
fastest=fastest or bool(re.search(r'(?:时效|速度)优先|优先(?:时效|速度)|更看重(?:时效|速度)',text)
|
||
or re.fullmatch(r'(?:我选|选|选择)?(?:时效|速度)[。.!!??\s]*',text))
|
||
cheapest=cheapest or bool(re.search(r'(?:预算|运费|成本|价格)优先|优先(?:预算|运费|成本|价格)|更看重(?:预算|运费|成本|价格)',text)
|
||
or re.search(r'(?:想|要|希望|尽量|主要|优先|更在意)(?:能)?(?:省钱|便宜|节省运费|控制成本)',text)
|
||
or re.fullmatch(r'(?:我选|选|选择)?(?:预算|运费|成本|价格|省钱)[。.!!??\s]*',text))
|
||
if fastest and cheapest:return 'fastest_cheapest'
|
||
if DIFFERENCE.search(text) and (fastest or slowest):return 'speed_compare'
|
||
if cheapest:return 'cheapest'
|
||
if fastest and slowest:return 'speed_compare'
|
||
if fastest:return 'fastest'
|
||
if slowest:return 'slowest'
|
||
return None
|
||
|
||
|
||
def request_mode(text):
|
||
# Business topics outrank generic verbs such as “怎么算”.
|
||
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
|
||
from .quote_edits import weight_comparison
|
||
if weight_comparison(text):return 'rounding'
|
||
if re.search(r'毛重|净重|计费重.*(?:什么|区别)|材积重.*(?:什么|区别)',text):return 'weight_basis'
|
||
if re.search(r'混装|混箱|不同产品.*一箱',text):return 'mixing'
|
||
if (re.search(r'(?:铁|钢|铝|金属).*(?:附加|加费|加钱|整票)|整票.*(?:铁|钢|铝|金属)',text)
|
||
and not re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text)):
|
||
return 'metal_scope'
|
||
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
|
||
from .shipping_faq import preparation_topic
|
||
if preparation_topic(text):return 'preparation'
|
||
if DIFFERENCE.search(text):return 'difference'
|
||
if re.search(r'展开(?:报价)?明细|展开(?:价格|费用)|详细报价|完整报价',text) or re.fullmatch(r'(?:那)?明细(?:呢)?[。.!!??\s]*',text):return 'quote_detail'
|
||
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价|总价多少|这个多少钱|那个多少钱',text) or re.fullmatch(r'(?:那)?一共(?:呢|多少)?[。.!!??\s]*',text):return 'total'
|
||
if re.fullmatch(r'第[一二三四五六七八九十\d]+(?:个|条|种)(?:方案|渠道)(?:呢)?[。.!!??\s]*',text):return 'clarify_route'
|
||
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.fullmatch(r'(?:我)?(?:选|选择|优先|更看重)?(?:时效|速度)(?:优先)?[。.!!??\s]*',text):return 'fastest'
|
||
if re.search(r'推荐|怎么走.*(?:好|合适)|走哪.*(?:好|合适)|选哪.*(?:好|合适)',text):return 'recommend'
|
||
if re.search(r'时效|多久|几天|多快(?:能|会)?到|什么时候(?:能|会|可以)?到|哪天|几号|何时(?:到|能到)|能收到',text) and not WEIGHT.search(text):return 'time'
|
||
return request_selection(text) or 'quote'
|
||
|
||
|
||
def parse_intent(message,warehouse_codes):
|
||
text=normalize(quote_question_text(message))
|
||
from .quote_edits import condition_edits,quantity_adjustment,ADJUSTMENT_QUESTION,VOLUME_QUESTION
|
||
edits=condition_edits(text)
|
||
text=edits['text']
|
||
text=re.sub(r'(?:而?不是|并非|不要按|不是按)\s*(?:每[箱件]|单[箱件])?\s*'+WEIGHT.pattern,' ',text,flags=re.I)
|
||
from .quote_material import material_intent
|
||
materials=material_intent(text,WEIGHT,kg)
|
||
quantity_text=materials['quantity_text']
|
||
change=quantity_adjustment(quantity_text)
|
||
if change:quantity_text=change['text']
|
||
parsed=parse_shipping_intent(quantity_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=materials['errors'][:]
|
||
pending_weight=re.search(r'(?:总重量|总重|重量|毛重|实重)\s*(?:改成|改为|改到|换成|调整为|是|为)?\s*'
|
||
r'(-?\d+(?:\.\d+)?)(?![\d.])(?!\s*(?:'+WEIGHT_UNITS+r')(?![A-Za-z]))',text,re.I)
|
||
if pending_weight:errors.append(MISSING_WEIGHT_UNIT)
|
||
weight_unit=re.fullmatch(r'(?:单位(?:是|用|为)?|按)?\s*('+WEIGHT_UNITS+r')(?:计算|算|计)?[。.!!??\s]*',text,re.I)
|
||
from .quote_allocations import allocations
|
||
assigned=allocations(quantity_text,warehouses,WEIGHT,kg)
|
||
weights=[m for m in WEIGHT.finditer(quantity_text) if not quantity_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?)',quantity_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(quantity_text))
|
||
package_dimensions={}
|
||
for index,match in enumerate(matches):
|
||
ordinal,n,value,unit=match.groups()
|
||
segment=quantity_text[match.end():matches[index+1].start() if index+1<len(matches) else len(quantity_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)})
|
||
package_dimensions[match.start(3)]={'dims':dims,'dim_unit':detect_dim_unit(segment)}
|
||
for part in assigned or []:
|
||
for piece in part.get('packages') or []:
|
||
piece.update(package_dimensions.get(piece.pop('position'),{}))
|
||
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_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 and not (change and change['kind']=='boxes'):
|
||
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.])',''),quantity_text,re.I)
|
||
if total and kg(*total.groups())!=Decimal(weight) and not (change and change['kind']=='boxes'):
|
||
errors.append('总重与分箱重量合计不一致,请确认。')
|
||
# Per-carton dimensions must not spill onto another carton without dimensions.
|
||
if len(packages)>1:parsed['dims']=None
|
||
elif assigned:
|
||
weight=str(sum(Decimal(a['weight']) for a in assigned))
|
||
if set(a['warehouse'] for a in assigned)!=set(warehouses):errors.append('请分别提供每个目的仓的重量,避免遗漏分票。')
|
||
total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,quantity_text,re.I)
|
||
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与各目的仓分配重量不一致,请确认。')
|
||
elif len(weights)>1 and request_mode(text)!='rounding':
|
||
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
|
||
if assigned and packages and Decimal(weight)!=sum(Decimal(a['weight']) for a in assigned):
|
||
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|立方米|立方|方)',quantity_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))
|
||
mode=request_mode(text)
|
||
from .quote_plans import plan_reference
|
||
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,'allocations':assigned,'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 edits['tax']),
|
||
'excluded_services':edits['excluded_services'],'clear_choice':edits['clear_choice'],
|
||
'compare_services':mode=='difference' or bool(re.search(r'更快|快一?点|更便宜|便宜一?点',text)),
|
||
'choice_pending':edits['clear_choice'] and request_selection(text) is None and not services,
|
||
'tax_pending':edits['tax_pending'],'quantity_change':change,
|
||
'quantity_uncertain':bool(change and change['kind']!='volume'),
|
||
'volume_pending':bool(change and change['kind']=='volume'),
|
||
'quantity_question':(VOLUME_QUESTION if change['kind']=='volume' else ADJUSTMENT_QUESTION) if change else None,
|
||
'errors':errors,'mode':mode,'selection':request_selection(text) or ('fastest_cheapest' if mode=='recommend' else None),
|
||
'plan_reference':plan_reference(message),'plan_handled':False,
|
||
'pending_weight':pending_weight[1] if pending_weight else None,
|
||
'weight_unit':weight_unit[1] if weight_unit else None,
|
||
'clear_selection':bool(re.search(r'(?:全部|所有|各个?)(?:的)?(?:渠道|线路|方案)|(?:全部|所有)(?:报价|价格|运价)',text)),
|
||
'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
|
||
'material':materials['material'],'metal_scope':materials['metal_scope'],
|
||
'metal_chargeable_weight':materials['metal_chargeable_weight'],
|
||
'metal_weight_update':materials['metal_weight_update'],
|
||
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
|
||
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
|
||
|
||
|
||
def is_followup(message,intent):
|
||
from .quote_plans import choice_confirmation
|
||
message=quote_question_text(message)
|
||
if resumes_quote(message):return True
|
||
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
|
||
if TOPIC_BOUNDARY.search(message):return False
|
||
if intent.get('plan_reference') is not None or choice_confirmation(message):return True
|
||
if intent.get('weight_unit') or intent.get('quantity_change'):return True
|
||
if intent.get('excluded_services') or intent.get('clear_choice') or intent.get('compare_services') or intent.get('tax_pending'):
|
||
return not (intent['country'] and intent['weight'])
|
||
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'具体(?:多少|多少钱)(?:呢)?',text):return True
|
||
if re.fullmatch(r'(?:请|请问|帮我|给我|请给我|麻烦)?(?:再|继续)?(?:提供|显示|列出|看一下|看看|查询)?'
|
||
r'(?:具体|详细|全部渠道|所有渠道|各渠道)?(?:报价|价格|运费|费用)(?:呢|是多少|多少|怎么样|如何|多少钱)?',text):return True
|
||
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','zone','material','metal_scope','metal_chargeable_weight')))
|