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

212 lines
14 KiB
Python

"""Resolve same-conversation quote edits and return local facts without model I/O."""
from decimal import Decimal, InvalidOperation
import re
import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
from .quote_intent import resumes_quote,quote_question_text,timing_with_claim
from .customer_faq import is_acknowledgement
def previous_quote_intents(conv_id,codes,resume=False,include_other=False,book=None,current=None):
"""Read the current quote back to its topic boundary, without a turn cutoff."""
earlier_intents=[]
entries=[]
resume_updates=[]
resume_entries=[]
if conv_id:
connection=connect()
try:
recent=connection.execute("SELECT id,content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,))
for message_id,previous in recent:
from .chat_questions import split_order_quote
compound=split_order_quote(previous)
if compound:previous=compound[1]
if is_acknowledgement(previous):continue
earlier=parse_intent(previous,codes)
if resumes_quote(previous):
earlier_intents.append(earlier)
entries.append((message_id,earlier))
# Retain edits made after an explicit return while crossing other topics.
resume_updates=earlier_intents[:]
resume_entries=entries[:]
resume=True
continue
quote_text=quote_question_text(previous)
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',quote_text))
quote_root=bool((earlier['country'] or earlier['warehouse'] or earlier.get('unknown_warehouses'))
and QUOTE_WORDS.search(previous))
if quote_root and not re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',quote_text):
boundary=False
boundary=boundary or bool(re.search(r'订单|运单|入仓码',previous) and not re.search(r'报价|海运|海派|海卡|空运',previous))
candidate=(QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal')))
if boundary or not candidate:
if not resume:break
earlier_intents=resume_updates[:]
entries=resume_entries[:]
continue
if earlier['country']=='other':
if include_other:earlier_intents.append(earlier)
break
earlier_intents.append(earlier)
entries.append((message_id,earlier))
if not is_followup(previous,earlier):break
if book and entries:
from .quote_plans import history_bindings,bind_reference
rows=connection.execute('SELECT id,role,content FROM messages WHERE conv_id=? AND id>=? ORDER BY id',
(conv_id,min(item[0] for item in entries)))
bindings,options,selected,mixed,pending=history_bindings(rows,book)
for message_id,earlier in entries:
if message_id in bindings:earlier['plan_option']=bindings[message_id]
if current is not None and options:
bind_reference(current,current['text'],options,selected,mixed)
if pending and current.get('plan_reference') is None and not any(current.get(key) for key in ('selection','services','clear_selection')):
current['errors'].append(pending)
finally:
connection.close()
return earlier_intents
def intent_with_context(message, conv_id, book):
from .quote_context import merge_intent
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
named_us_service=bool(re.search(r'美森|\bEXX\b',intent['text'],re.I)) and intent['country'] is None and not intent['warehouse']
plan_resume=intent.get('plan_reference') is not None and bool(re.search(r'方案|渠道|线路|\b(?:option|plan)',message,re.I))
earlier_intents=(previous_quote_intents(conv_id,codes,resume=resumes_quote(message) or plan_resume,include_other=named_us_service,book=book,current=intent)
if is_followup(message,intent) else [])
if intent.get('quantity_change') and not earlier_intents:
intent['errors'].append(intent['quantity_question'])
if intent.get('metal_weight_update') and not earlier_intents:
intent['metal_chargeable_weight']=None
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if earlier_intents:
state=earlier_intents[-1]
if state.get('metal_weight_update'):state['metal_chargeable_weight']=None
for update in list(reversed(earlier_intents[:-1]))+[intent]:
state=merge_intent(update,state)
intent=state
if (intent['warehouse'] or intent.get('unknown_warehouses') or named_us_service) and intent['country'] is None:
intent['country']='US'
return intent
def answer_plan_question(message,conv_id):
from .quote_plans import plan_reference,choice_confirmation
if not conv_id or (plan_reference(message) is None and not choice_confirmation(message)):return None
return answer_quote(message,conv_id,plan_only=True)
def answer_quote(message, conv_id,plan_only=False,request=None):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', quote_question_text(message)):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
from .shipping_faq import preparation_topic
if not (US.search(message) or QUOTE_WORDS.search(message) or preparation_topic(message) or conv_id or
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None
try:
book = load_book()
if not book:
return None
intent = intent_with_context(message, conv_id, book)
if request and request['request'] == 'policy' and request['policy_topic'] != 'auto':
intent['mode'] = request['policy_topic']
if request and request['request'] != 'policy':
intent['mode'] = {'details': 'quote_detail'}.get(request['request'], request['request'])
selection = request['selection']
if selection != 'current':
intent['selection'] = None if selection == 'all' else selection
if selection != 'all' or request['services']:
intent['services'] = request['services'] or None
intent.pop('selected_plan_number', None)
intent.pop('plan_unit', None)
elif request['services']:
intent['services'] = request['services']
if plan_only and not intent.get('plan_handled'):return None
if intent['country'] != 'US':
if intent['country']=='other' and re.search(r'美森|\bEXX\b',message,re.I) and not US.search(message):
return {'t':'您前面咨询的是其他目的地,美森和EXX在当前资料中是美国渠道。请确认这票是否发往美国,再继续核对时效和价格。'}
if intent['country'] is None and intent.get('mode')=='recommend':
return {'t':'请提供目的国家、货物重量和目的仓码或邮编,我再比较时效优先与运费优先的方案。'}
if intent.get('quantity_change'):
if intent['quantity_change']['kind']=='volume':
return {'t':'请先提供原来的总体积和目的国家、目的仓,再说明增减量。'}
return {'t':'请先提供原来的总重、箱数和目的国家,再说明增减量。'}
if resumes_quote(message):
return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'}
if intent.get('mode')=='clarify_route':
return {'t':'请先提供目的国家、重量和所需渠道,再继续核对具体方案。'}
if intent.get('weight_unit'):
return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'}
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None
if intent.get('tax_pending') and '请明确选择包税或不包税方案。' not in intent['errors']:
intent['errors'].append('请明确选择包税或不包税方案。')
if intent.get('choice_pending') and '请指定希望使用的渠道,或选择最快、最慢、最便宜方案。' not in intent['errors']:
intent['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。')
if intent.get('errors'):
return {'t':'\n'.join(intent['errors'])}
if intent.get('mode')=='clarify_route':
return {'t':'请指定方案名称,例如美森正班或普船,或说“最快”“最慢”“最便宜”,我按当前货物条件继续核算。'}
if intent.get('mode')=='difference':
if intent.get('selection') not in ('fastest','slowest','speed_compare','fastest_cheapest'):
return {'t':'请明确要比较的方案,可直接问“最快和最慢相差多少”,我按当前货物条件核算差额。'}
if intent['selection']!='fastest_cheapest':intent['selection']='speed_compare'
if intent.get('unknown_warehouses'):
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
return None
for key in ('weight','cbm'):
if intent[key] is not None and not (Decimal(0) < Decimal(intent[key]) <= Decimal(1000000)):
return {'t':'请提供大于 0 的有效重量或体积。'}
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
return {'t':'请提供有效的货物箱数。'}
dimensions=[intent['dims']] if intent['dims'] else []
dimensions += [piece['dims'] for piece in intent.get('packages') or [] if piece.get('dims')]
if any(not 0 < value <= 100000 for dims in dimensions for value in dims):
return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出,空运承运条件也需单独确认。'}
from .quote_answers import answer_business
preparation=None
if intent.get('mode')=='preparation':
from .shipping_faq import answer_preparation
preparation=answer_preparation(intent,book)
if preparation_topic(message)!='schedule' or not re.search(r'\d+\s*(?:KG|公斤|千克|克|吨|方|CBM)',intent['text'],re.I):
return {'t':preparation}
intent['mode']='cheapest' if re.search(r'最便宜|最划算|最低价',message) else 'quote'
business=answer_business(intent,book)
if business is not None:return {'t':business}
destinations = variants(intent,book)
fees = []
for destination in destinations:
postal = destination['postal']
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
reply=render_rates(intent,book,destinations,fees)
if timing_with_claim(message):
from .quote_answers import timing_liability
reply+='\n'+timing_liability(intent,book,destinations)
if intent.get('mode')=='total':
reply+='\n以上为已提供条件下可计算的运费,各方案按所列包税/不包税方式分别核算;目的仓、箱规及未确定附加费需核实后才能确认最终应付总价。'
if intent.get('mode')=='cheapest' and re.search(r'便宜一?点|折扣|优惠|打折',message):
reply+='\n以上按当前价表比较,折扣或合作价格需人工确认,尚未修改单价。'
answer = {'t':reply+('\n'+preparation if preparation else '')}
if request:
answer['_confirmed_quote'] = {key: intent.get(key) for key in (
'weight', 'cbm', 'boxes', 'dims', 'packages', 'allocations', 'warehouse', 'postal', 'zone',
'origin', 'services', 'tax') if intent.get(key) is not None}
return answer
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'}