165 lines
8.4 KiB
Python
165 lines
8.4 KiB
Python
"""Bind followups to displayed quote identities and recalculate from the active book."""
|
||
|
||
from copy import deepcopy
|
||
import re
|
||
|
||
from .quote_intent import chinese_number
|
||
|
||
|
||
HEADINGS={'最快参考方案':'fastest','最慢参考方案':'slowest','最便宜参考方案':'cheapest',
|
||
'Fastest reference options':'fastest','Slowest reference options':'slowest','Cheapest reference options':'cheapest'}
|
||
|
||
|
||
def choice_confirmation(message):
|
||
return bool(re.fullmatch(r'(?:就|先)?(?:选|选择|按|用)?(?:这个|这一个)(?:方案)?(?:吧|就行)?[。.!!??\s]*',message.strip()))
|
||
|
||
|
||
def plan_reference(message):
|
||
if re.search(r'订单|运单|入仓码|票|箱',message):return None
|
||
text=re.sub(r'[\s,,。.!!??]','',message)
|
||
numbered=re.fullmatch(r'(?:我)?(?:选|选择|用|看|换成|改成)?(?:方案|渠道|线路|(?i:option|plan))(\d{1,4})(?:的)?(.*)',text)
|
||
if numbered:return int(numbered[1])
|
||
match=re.fullmatch(r'(?:那|我|请|帮我|看看)?(?:选|选择|用|看|换成|改成)?第(\d{1,4}|[一二三四五六七八九十百]{1,4})'
|
||
r'(?:个|条|种)?(方案|渠道|线路)?(?:的)?(.*)',text)
|
||
if not match:return None
|
||
if not match[2] and not re.fullmatch(r'呢|吧|多少钱|价格|费用|运费|时效|多久(?:能到|能提取)?|详情|明细|',match[3]):return None
|
||
return int(match[1]) if match[1].isdigit() else int(chinese_number(match[1]))
|
||
|
||
|
||
def displayed_options(content,book):
|
||
from .quote_language import translate_quote
|
||
|
||
if re.search(r'当前按方案\d+继续核算|Continuing with option \d+',content):return []
|
||
services={name:name for name in {rate['service'] for rate in book['rates']}}
|
||
services.update({translate_quote(name):name for name in list(services)})
|
||
kinds={rate['service']:rate['kind'] for rate in book['rates']}
|
||
origins={name:name for name in {rate['origin'] for rate in book['rates']}}
|
||
origins.update({translate_quote(name):name for name in list(origins)})
|
||
warehouses={row['code'] for row in book['warehouses']}
|
||
options=[]
|
||
current=None
|
||
destination={}
|
||
for line in content.splitlines():
|
||
label=line.rstrip(':: ')
|
||
code=label.split(' · ')[0]
|
||
if code in warehouses or re.fullmatch(r'[A-Z]{2,4}[0-9]',code):
|
||
destination={'warehouse':code,'destination_unavailable':code not in warehouses}
|
||
if ' · ' in label:
|
||
destination['warehouse_zone']=label.split(' · ',1)[1]
|
||
destination['destination_unavailable']=not any(row['code']==code and row['zone']==destination['warehouse_zone'] for row in book['warehouses'])
|
||
elif re.fullmatch(r'\d{5}',code):destination={'postal':code}
|
||
elif code in ('美西','美中','美东'):destination={'zone':code}
|
||
heading=re.match(r'^(?:(?:方案|Option\s+)(\d+)\s*·\s*)?('+ '|'.join(map(re.escape,HEADINGS))+r')',line)
|
||
if heading:
|
||
current={'number':int(heading[1]) if heading[1] else len(options)+1,'label':heading[2],
|
||
'selection':HEADINGS[heading[2]],'rows':[],**destination}
|
||
options.append(current)
|
||
continue
|
||
row=re.match(r'^- (?:(?:方案|Option\s+)(\d+)\s*·\s*)?(.+?) · (.+?) · (.+)',line)
|
||
timing_row=False
|
||
if row is None:
|
||
row=re.match(r'^- (?:(?:方案|Option\s+)(\d+)\s*·\s*)?(.+?) · ([^,,]+)[,,](.+)',line)
|
||
timing_row=True
|
||
if not row:continue
|
||
tax='excluded' if row[4].startswith(('不包税','tax excluded')) else 'included' if row[4].startswith(('包税','tax included')) else None
|
||
if tax is None and not timing_row:continue
|
||
if current is None or row[1]:
|
||
current={'number':int(row[1]) if row[1] else len(options)+1,'label':row[2]+' · '+row[3],
|
||
'selection':None,'rows':[],**destination}
|
||
options.append(current)
|
||
unit=re.search(r'(?:元|CNY)/(KG|CBM)',row[4],re.I)
|
||
if row[2] in services and row[3] in origins:
|
||
current['rows'].append({'service':services[row[2]],'origin':origins[row[3]],'tax':tax,
|
||
'kind':kinds[services[row[2]]],'unit':unit[1].lower() if unit else None})
|
||
if current['selection'] is None:current=None
|
||
return options
|
||
|
||
|
||
def bind_reference(intent,message,options,selected=False,mixed=False):
|
||
number=plan_reference(message)
|
||
if number is None:
|
||
if not (choice_confirmation(message) and len(options)>1 and not selected):return
|
||
intent['errors'].append('请明确选择'+ '、'.join('方案'+str(option['number']) for option in options)+',我再按该方案继续核算。')
|
||
intent['plan_handled']=True
|
||
return
|
||
intent['plan_handled']=True
|
||
if mixed and not re.search(r'方案|渠道|线路',message):
|
||
intent['errors'].append('您指的是第'+str(number)+'个订单,还是报价方案'+str(number)+'?')
|
||
return
|
||
option=next((option for option in options if option['number']==number),None)
|
||
if option is None:
|
||
intent['errors'].append('刚才展示了'+str(len(options))+'个方案,请选择其中的方案编号。')
|
||
elif option.get('destination_unavailable'):
|
||
intent['errors'].append('所选方案的目的仓分区已更新,请重新查看报价。')
|
||
intent['plan_unavailable']=intent['errors'][-1]
|
||
elif not option['rows']:
|
||
intent['errors'].append('方案'+str(number)+'暂无可用报价,请选择其他方案或补充货物条件。')
|
||
intent['plan_unavailable']=intent['errors'][-1]
|
||
else:
|
||
intent['plan_option']=option
|
||
if intent['mode']=='clarify_route':intent['mode']='quote'
|
||
|
||
|
||
def history_bindings(rows,book):
|
||
from .quote_intent import parse_intent
|
||
|
||
options=[]
|
||
selected=False
|
||
mixed=False
|
||
bindings={}
|
||
previous_reference=None
|
||
pending=None
|
||
for message_id,role,content in rows:
|
||
if role=='assistant':
|
||
shown=displayed_options(content,book)
|
||
if shown and (not options or len(shown)>1 and previous_reference is None):
|
||
options=shown
|
||
mixed=any(line.startswith('- 入仓码:') for line in content.splitlines())
|
||
continue
|
||
if role!='user':continue
|
||
previous_reference=plan_reference(content)
|
||
if previous_reference is not None:
|
||
probe=parse_intent(content,set())
|
||
bind_reference(probe,content,options,selected,mixed)
|
||
if probe.get('plan_option'):
|
||
bindings[message_id]=probe['plan_option']
|
||
selected=True
|
||
pending=None
|
||
elif probe.get('plan_unavailable'):pending=probe['plan_unavailable']
|
||
else:
|
||
parsed=parse_intent(content,set())
|
||
if parsed.get('selection') in ('fastest','slowest','cheapest') or parsed.get('services'):
|
||
selected=True
|
||
pending=None
|
||
elif parsed.get('selection') or parsed.get('clear_selection'):
|
||
selected=False
|
||
pending=None
|
||
return bindings,options,selected,mixed,pending
|
||
|
||
|
||
def selected_intent(current,previous):
|
||
option=current.get('plan_option')
|
||
if not option:return current
|
||
result=deepcopy(current)
|
||
rows=option['rows']
|
||
result['services']=list(dict.fromkeys(row['service'] for row in rows))
|
||
result['kinds']=list(dict.fromkeys(row['kind'] for row in rows))
|
||
units=list(dict.fromkeys(row['unit'] for row in rows))
|
||
result['plan_unit']=units[0] if len(units)==1 else None
|
||
origins=list(dict.fromkeys(row['origin'] for row in rows))
|
||
result['origin']=origins[0] if len(origins)==1 else None
|
||
taxes=list(dict.fromkeys(row['tax'] for row in rows))
|
||
result['tax']=taxes[0] if len(taxes)==1 else None
|
||
result['selection']=None
|
||
result['selected_plan_number']=option['number']
|
||
for key in ('warehouse','postal','zone'):
|
||
if option.get(key):result[key]=option[key]
|
||
if option.get('warehouse'):result['warehouses']=[option['warehouse']]
|
||
if option.get('warehouse_zone'):result['plan_destination']={'warehouse':option['warehouse'],'zone':option['warehouse_zone']}
|
||
if option.get('zone'):result['zones']=[option['zone']]
|
||
if option.get('warehouse') and previous.get('allocations'):
|
||
part=next((part for part in previous['allocations'] if part['warehouse']==option['warehouse']),None)
|
||
if part:result.update(weight=part['weight'],boxes=part.get('boxes'),packages=part.get('packages'))
|
||
result.pop('plan_option',None)
|
||
return result
|