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

263 lines
19 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_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'理赔|索赔|赔(?:偿|付|钱|不赔|吗|嘛|么)|补偿|费用.*谁.*(?:承担|负责)')
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 re.search(r'差价|相差|差多少|贵多少|便宜多少',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 re.search(r'差价|相差|差多少|贵多少|便宜多少',text):return 'difference'
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)
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':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),
'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):
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('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')))