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

165 lines
8.4 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.
"""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