194 lines
12 KiB
Python
194 lines
12 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):
|
|
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 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以上按当前价表比较,折扣或合作价格需人工确认,尚未修改单价。'
|
|
return {'t':reply+('\n'+preparation if preparation else '')}
|
|
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
|
|
return {'t':'报价数据暂时无法读取,请稍后重试。'}
|