完善混合提问与报价连续追问
This commit is contained in:
@@ -13,6 +13,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
|
||||
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
|
||||
| Reply presentation | `static/js/reply.js`: `ChatReply.create`; `chat.js`: `doSend`, network completion and display queue cancellation; `tests/frontend/reply.test.cjs` |
|
||||
| Compound / quote followups | `logistics_chat/chat_questions.py`: `split_order_quote`, `answer_compound`; `quote_plans.py`: displayed option identities and chronological references; `quote_followup_fields.py`: missing shipment fields; `quote_comparison.py`: compact differences |
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`, `quote_question_text`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`, `select_cheapest_rates`; `quote_answers.py`: `timing_liability`; `quote_fees.py`: local business terms and fees |
|
||||
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
|
||||
|
||||
@@ -83,6 +83,8 @@ tests/ # 按业务模块组织的回归
|
||||
|
||||
报价支持同时比较最快和最便宜方案,推荐追问保留货物条件并按预算、时效分别列出选择。明确指定渠道会替换先前的比较选择。`quote_question_text` 从混合时效与赔付问句中提取报价条件,`timing_liability` 只引用当前匹配渠道已有的时效赔偿条款,其他费用由人工核实。独立理赔话题保留原有话题边界,其他目的地的上下文需先确认国家再使用美国渠道。
|
||||
|
||||
`chat_questions.py` 将明确组合的订单统计与报价分开处理,再合并为一条回复,沿用订单部分的客户绑定、登录提示和列表状态。`quote_plans.py` 从当前报价话题内保存的实际回复识别方案编号,仅绑定当前价表中的渠道、交仓地、税费、计费单位和目的仓分区,并重新计算金额。多候选的“这个”及订单与报价之间的裸序号会先澄清。方案失效后要求重新选择。`quote_followup_fields.py` 只追问缺项,`quote_comparison.py` 先给同口径差额与参考时效,展开明细后恢复完整报价。
|
||||
|
||||
报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。
|
||||
|
||||
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Separate explicit order statistics and freight questions without model calls."""
|
||||
|
||||
import re
|
||||
|
||||
from .order_query import parse_query
|
||||
from .quote_intent import US,QUOTE_WORDS
|
||||
|
||||
|
||||
def split_order_quote(message):
|
||||
parts=[part.strip() for part in re.split(r'[,,;;。??]+|同时|另外(?:问)?|顺便(?:问)?|以及|还有|并且',message) if part.strip()]
|
||||
orders=[]
|
||||
freight=[]
|
||||
for part in parts:
|
||||
if parse_query(part) is not None:orders.append(part)
|
||||
else:freight.append(part)
|
||||
quote=','.join(freight)
|
||||
if not orders or not quote or not (US.search(quote) or QUOTE_WORDS.search(quote)):
|
||||
return None
|
||||
return ','.join(orders),quote
|
||||
|
||||
|
||||
def answer_compound(message,conv_id):
|
||||
parts=split_order_quote(message)
|
||||
if parts is None:return None
|
||||
from .order_data import answer_query
|
||||
from .quote_service import answer_quote
|
||||
from .quote_scope import answer_us_fallback
|
||||
|
||||
orders,freight=parts
|
||||
order_answer=answer_query(orders,conv_id)
|
||||
if order_answer is None or 't' not in order_answer:return None
|
||||
quote_answer=answer_quote(freight,conv_id) or answer_us_fallback(freight,conv_id)
|
||||
if quote_answer is None:
|
||||
quote_answer={'t':'报价部分暂未匹配到当前资料,请提供目的国家、目的仓码或邮编及重量,由人工核对。'}
|
||||
return {**order_answer,'t':order_answer['t']+'\n\n'+quote_answer['t']}
|
||||
@@ -90,7 +90,13 @@ def create_chat_stream(msg, conv_id):
|
||||
if needs_customer_login(conv_id):
|
||||
return (sse(order_login_response()) + sse({'d':1}), {})
|
||||
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
from .chat_questions import answer_compound
|
||||
order_answer = answer_compound(msg,conv_id)
|
||||
if order_answer is None:
|
||||
from .quote_service import answer_plan_question
|
||||
order_answer=answer_plan_question(msg,conv_id)
|
||||
if order_answer is None:
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
if order_answer is None:
|
||||
from .customer_faq import answer_capability
|
||||
order_answer = answer_capability(msg)
|
||||
|
||||
@@ -5,20 +5,21 @@ from decimal import Decimal,ROUND_HALF_UP
|
||||
from .quote_fees import charge_details
|
||||
|
||||
|
||||
def speed_difference_lines(intent, book, rates, fees):
|
||||
from .quote_engine import quantity, number, select_timed_rates,select_cheapest_rates
|
||||
def speed_differences(intent, book, rates, fees):
|
||||
from .quote_engine import quantity, select_timed_rates,select_cheapest_rates,reference_days
|
||||
|
||||
groups = {}
|
||||
for rate in rates:
|
||||
key = (rate['origin'], rate['zone'], rate['kind'], rate['unit'], rate['tax'])
|
||||
groups.setdefault(key, []).append(rate)
|
||||
lines = []
|
||||
result = []
|
||||
for (origin, zone, kind, unit, tax), candidates in groups.items():
|
||||
costs = []
|
||||
cheapest=intent.get('selection')=='fastest_cheapest'
|
||||
comparison=(select_cheapest_rates(intent,book,candidates,fees) if cheapest
|
||||
else select_timed_rates(candidates,slowest=True))
|
||||
for selected in (select_timed_rates(candidates),comparison):
|
||||
selections=(select_timed_rates(candidates),comparison)
|
||||
for selected in selections:
|
||||
values = []
|
||||
for rate in selected:
|
||||
amount = quantity(intent, rate)
|
||||
@@ -30,9 +31,58 @@ def speed_difference_lines(intent, book, rates, fees):
|
||||
if not fast or not slow:
|
||||
continue
|
||||
low, high = min(fast) - max(slow), max(fast) - min(slow)
|
||||
label = ' · '.join(value for value in (origin, zone, '包税' if tax == 'included' else '不包税',
|
||||
'海派' if kind == 'parcel' else '海卡/海铁') if value)
|
||||
difference = number(low) if low == high else number(low) + '至' + number(high)
|
||||
pair='最快与最便宜方案' if cheapest else '快慢方案'
|
||||
timing=[]
|
||||
for selected in selections:
|
||||
bounds=[reference_days(rate) for rate in selected if reference_days(rate)]
|
||||
timing.append((min(value[0] for value in bounds),max(value[1] for value in bounds)) if bounds else None)
|
||||
result.append({'origin':origin,'zone':zone,'kind':kind,'unit':unit,'tax':tax,
|
||||
'low':low,'high':high,'timing':timing,'cheapest':cheapest})
|
||||
return result
|
||||
|
||||
|
||||
def speed_difference_lines(intent, book, rates, fees):
|
||||
from .quote_engine import number
|
||||
|
||||
lines=[]
|
||||
for row in speed_differences(intent,book,rates,fees):
|
||||
label=' · '.join(value for value in (row['origin'],row['zone'],'包税' if row['tax']=='included' else '不包税',
|
||||
'海派' if row['kind']=='parcel' else '海卡/海铁') if value)
|
||||
difference=number(row['low']) if row['low']==row['high'] else number(row['low'])+'至'+number(row['high'])
|
||||
pair='最快与最便宜方案' if row['cheapest'] else '快慢方案'
|
||||
lines.append(f'- {label},{pair}可计算运费差额为{difference}元。')
|
||||
return lines
|
||||
|
||||
|
||||
def render_difference(intent,book,destinations,remote_fees):
|
||||
from .quote_engine import match_rates,number,exact_number
|
||||
from .quote_language import translate_quote
|
||||
|
||||
groups={}
|
||||
for destination,fees in zip(destinations,remote_fees):
|
||||
part=destination.get('allocation')
|
||||
billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')}
|
||||
if part else intent)
|
||||
if part and intent.get('metal_chargeable_weight') is not None:
|
||||
billing.update(metal_chargeable_weight=None,metal_scope='partial')
|
||||
for row in speed_differences(billing,book,match_rates(billing,book,destination),fees):
|
||||
key=(row['origin'],row['kind'],row['unit'],row['tax'],row['low'],row['high'],tuple(row['timing']),
|
||||
row['cheapest'],destination['warehouse'] or destination['postal'],billing.get('weight'))
|
||||
groups.setdefault(key,[]).append(row['zone'])
|
||||
if not groups:
|
||||
return ('Please provide weight, carton dimensions and routes with numeric reference transit times to compare costs.'
|
||||
if intent.get('english') else '请提供重量、箱规及有明确数值时效的渠道,继续核算方案差额。')
|
||||
lines=['按当前条件,同一交仓地、区域、计费单位和税费口径比较。']
|
||||
for (origin,kind,unit,tax,low,high,timing,cheapest,destination,weight),zones in groups.items():
|
||||
labels=[origin,destination or '/'.join(dict.fromkeys(zone for zone in zones if zone)),
|
||||
'包税' if tax=='included' else '不包税','海派' if kind=='parcel' else '海卡/海铁']
|
||||
difference=number(low) if low==high else number(low)+'至'+number(high)
|
||||
pair='最快与最便宜方案' if cheapest else '快慢方案'
|
||||
line='- '+' · '.join(label for label in labels if label)+f',{pair}可计算运费差额为{difference}元。'
|
||||
if weight:line+=f'按{exact_number(weight)}KG核算。'
|
||||
if all(timing):
|
||||
days=[number(bounds[0]) if bounds[0]==bounds[1] else number(bounds[0])+'–'+number(bounds[1]) for bounds in timing]
|
||||
line+='参考时效分别约'+days[0]+'天、'+days[1]+'天。'
|
||||
lines.append(line)
|
||||
lines.append('回复“展开报价明细”可查看单价和各方案总价。')
|
||||
text='\n'.join(lines)
|
||||
return translate_quote(text) if intent.get('english') else text
|
||||
|
||||
@@ -120,9 +120,19 @@ def apply_quantity_change(result,current,previous):
|
||||
|
||||
|
||||
def merge_intent(current,previous):
|
||||
from .quote_plans import selected_intent
|
||||
current=selected_intent(current,previous)
|
||||
result=deepcopy(current)
|
||||
excluded={'mode','selection','clear_selection','clear_choice','compare_services','choice_pending','excluded_services','quantity_change','tax_pending','volume_pending','quantity_question',
|
||||
'text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'}
|
||||
'text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit',
|
||||
'plan_reference','plan_option','plan_handled'}
|
||||
if (current.get('selection') or current.get('clear_selection') or current.get('clear_choice') or current.get('services')) and not current.get('selected_plan_number'):
|
||||
excluded.update(('selected_plan_number','plan_unit','plan_destination'))
|
||||
if current.get('plan_reference') is not None:excluded.add('plan_unit')
|
||||
if current.get('plan_reference') is not None and current.get('warehouse') and previous.get('allocations'):
|
||||
excluded.update(('cbm','dims'))
|
||||
if any(current.get(key) for key in ('warehouse','postal','zone')) and not current.get('plan_destination'):
|
||||
excluded.add('plan_destination')
|
||||
unit=current.get('weight_unit')
|
||||
pending=previous.get('pending_weight')
|
||||
weight_change=current['weight'] is not None or bool(unit and pending is not None)
|
||||
@@ -148,6 +158,8 @@ def merge_intent(current,previous):
|
||||
if current.get('clear_selection'):
|
||||
excluded.update(('services','kinds','transport','air_only','air_requested'))
|
||||
if current.get('compare_services'):excluded.add('services')
|
||||
if previous.get('selected_plan_number') and current.get('selection') and not current.get('services'):
|
||||
excluded.add('services')
|
||||
blocked=[] if current.get('clear_selection') else previous.get('excluded_services') or []
|
||||
blocked=list(dict.fromkeys(blocked+(current.get('excluded_services') or [])))
|
||||
if current.get('excluded_services'):
|
||||
|
||||
@@ -33,6 +33,9 @@ def variants(intent, book):
|
||||
warehouse = intent['warehouse']
|
||||
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
|
||||
zoned = [r for r in found if r['zone']]
|
||||
selected_destination=intent.get('plan_destination')
|
||||
if selected_destination and selected_destination['warehouse']==warehouse:
|
||||
zoned=[row for row in zoned if row['zone']==selected_destination['zone']]
|
||||
if zoned:
|
||||
unique = {}
|
||||
for row in zoned:
|
||||
@@ -89,6 +92,7 @@ def match_rates(intent, book, variant):
|
||||
selected = []
|
||||
kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel'])
|
||||
for rate in book['rates']:
|
||||
if intent.get('plan_unit') and rate['unit']!=intent['plan_unit']:continue
|
||||
if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']):
|
||||
continue
|
||||
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
|
||||
@@ -129,16 +133,22 @@ def quantity_number(value,intent):
|
||||
return '约'+number(value,6)
|
||||
|
||||
|
||||
def reference_days(rate):
|
||||
match=re.search(r'(?<![\d.])(\d+(?:\.\d+)?)\s*(?:[-—–~~至]\s*(\d+(?:\.\d+)?))?\s*(?:天|days?)',rate['eta'],re.I)
|
||||
if not match:return None
|
||||
low=Decimal(match[1])
|
||||
high=Decimal(match[2] or match[1])
|
||||
return (low,high) if low>0 and high>=low else None
|
||||
|
||||
|
||||
def select_timed_rates(rates,slowest=False,by_origin=False):
|
||||
"""Compare numeric day ranges within one region, service kind and billing basis."""
|
||||
ranked=[]
|
||||
best={}
|
||||
for rate in rates:
|
||||
match=re.search(r'(?<![\d.])(\d+(?:\.\d+)?)\s*(?:[-—–~~至]\s*(\d+(?:\.\d+)?))?\s*(?:天|days?)',rate['eta'],re.I)
|
||||
if not match:continue
|
||||
low=Decimal(match[1])
|
||||
high=Decimal(match[2] or match[1])
|
||||
if low<=0 or high<low:continue
|
||||
days=reference_days(rate)
|
||||
if not days:continue
|
||||
low,high=days
|
||||
key=(rate['zone'],rate['kind'],rate['unit'],rate['tax'])
|
||||
if by_origin:key+=(rate['origin'],)
|
||||
timing=(high,low) if slowest else (low,high)
|
||||
@@ -168,6 +178,7 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
from .quote_language import translate_quote
|
||||
|
||||
lines = []
|
||||
plan_number=0
|
||||
rate_selection=intent.get('selection') or intent.get('mode')
|
||||
if intent.get('mode')=='time':
|
||||
lines.append('美国线路参考时效:')
|
||||
@@ -176,6 +187,7 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
else:
|
||||
lines.append('美国线路报价:')
|
||||
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
|
||||
if intent.get('selected_plan_number'):lines.append(f'当前按方案{intent["selected_plan_number"]}继续核算。')
|
||||
if intent.get('mode')=='recommend':
|
||||
lines.append({'fastest':'按您优先时效的要求,可考虑下面的较快方案。',
|
||||
'cheapest':'按您优先运费的要求,可考虑下面的较低价方案。'}.get(rate_selection,
|
||||
@@ -188,6 +200,9 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
|
||||
if intent.get('air_requested') and not intent.get('air_only'):
|
||||
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
|
||||
if intent.get('mode')=='difference':
|
||||
from .quote_comparison import render_difference
|
||||
return render_difference(intent,book,destinations,remote_fees)
|
||||
for destination, fees in zip(destinations, remote_fees):
|
||||
part=destination.get('allocation')
|
||||
billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')}
|
||||
@@ -207,9 +222,9 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
if rate_selection in ('fastest','slowest','speed_compare','fastest_cheapest'):
|
||||
selections=[]
|
||||
if rate_selection in ('fastest','speed_compare','fastest_cheapest'):
|
||||
selections.append(('最快参考方案',select_timed_rates(rates,by_origin=intent.get('mode')=='difference')))
|
||||
selections.append(('最快参考方案',select_timed_rates(rates,by_origin=intent.get('mode')=='quote_detail')))
|
||||
if rate_selection in ('slowest','speed_compare'):
|
||||
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True,by_origin=intent.get('mode')=='difference')))
|
||||
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True,by_origin=intent.get('mode')=='quote_detail')))
|
||||
if rate_selection=='fastest_cheapest':
|
||||
selections.append(('最便宜参考方案',select_cheapest_rates(billing,book,rates,fees,zone=destination['zone'])))
|
||||
lines.append('按相同区域、运输方式、计费单位及税费方式比较。' if rate_selection=='fastest_cheapest'
|
||||
@@ -217,14 +232,16 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
grouped = {}
|
||||
for selection,selected_rates in selections:
|
||||
if selection and not selected_rates:
|
||||
lines.append(selection+('暂时无法确定,请提供货物重量和箱规。' if selection=='最便宜参考方案' else '暂时无法确定,匹配线路未列明可比较的天数。'))
|
||||
plan_number+=1
|
||||
lines.append(f'方案{plan_number} · '+selection+('暂时无法确定,请提供货物重量和箱规。' if selection=='最便宜参考方案' else '暂时无法确定,匹配线路未列明可比较的天数。'))
|
||||
for rate in selected_rates:
|
||||
key = (selection, rate['service'], rate['origin'], rate['unit'], rate['tax'], rate['eta'])
|
||||
grouped.setdefault(key, []).append(rate)
|
||||
last_selection=None
|
||||
for (selection,service, origin, unit, tax,eta_source), rows in grouped.items():
|
||||
if selection and selection!=last_selection:
|
||||
lines.append(selection)
|
||||
plan_number+=1
|
||||
lines.append(f'方案{plan_number} · '+selection)
|
||||
last_selection=selection
|
||||
eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip()
|
||||
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
|
||||
@@ -249,35 +266,30 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
elif intent.get('cbm') and rate['unit']=='cbm':
|
||||
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
|
||||
parts.append(rate_text)
|
||||
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
|
||||
prefix=''
|
||||
if not selection and not intent.get('selected_plan_number'):
|
||||
plan_number+=1
|
||||
prefix=f'方案{plan_number} · '
|
||||
lines.append(f"- {prefix}{service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
|
||||
+ (f'({eta})' if eta else ''))
|
||||
if intent.get('mode')=='difference':
|
||||
from .quote_comparison import speed_difference_lines
|
||||
differences=speed_difference_lines(billing,book,rates,fees)
|
||||
if differences:
|
||||
compared='最便宜' if rate_selection=='fastest_cheapest' else '最慢'
|
||||
lines.append(f'以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去{compared}方案。')
|
||||
lines.extend(differences)
|
||||
else:
|
||||
lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。')
|
||||
if fees:
|
||||
if fees['fee'] is None:
|
||||
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
|
||||
else:
|
||||
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
|
||||
if not any(intent.get(key) for key in ('warehouse','postal')):
|
||||
if intent.get('mode')=='time' and not any(intent.get(key) for key in ('warehouse','postal')):
|
||||
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
|
||||
if intent.get('mode')=='time':
|
||||
lines.append('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。')
|
||||
if re.search(r'哪天|几号|什么时候|何时|收到',intent['text']):
|
||||
lines.append('请补充预计开船日期和目的仓码或邮编,再核对具体到货日期。')
|
||||
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
|
||||
lines.append('货物多少公斤、几箱?')
|
||||
elif not intent['boxes'] or not intent['dims']:
|
||||
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
|
||||
elif not (intent.get('mode')=='recommend' and rate_selection=='fastest_cheapest'):
|
||||
from .quote_followup_fields import missing_fields
|
||||
question=missing_fields(intent)
|
||||
if question:lines.append(question)
|
||||
if intent.get('mode')=='calculation':
|
||||
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
|
||||
if intent.get('mode')=='recommend' and rate_selection=='fastest_cheapest':
|
||||
lines.append('您更看重预算还是时效?提供目的仓码或邮编、交仓地后,可继续缩小方案。')
|
||||
lines.append('您更看重预算还是时效?')
|
||||
text='\n'.join(lines)
|
||||
return translate_quote(text) if intent.get('english') else text
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Ask only for shipment fields absent from the current effective quote."""
|
||||
|
||||
|
||||
def missing_fields(intent):
|
||||
fields=[]
|
||||
if not (intent['warehouse'] or intent['postal']):fields.append('目的仓码或邮编')
|
||||
packages=intent.get('packages') or []
|
||||
allocations=intent.get('allocations') or []
|
||||
boxes=intent.get('boxes') or packages or allocations and all(part.get('boxes') or part.get('packages') for part in allocations)
|
||||
if not boxes:fields.append('箱数')
|
||||
if not intent['weight'] and not allocations:
|
||||
fields.append('总重或各箱重量')
|
||||
elif not packages and not (intent.get('boxes')==1 and intent['weight']) and not (
|
||||
allocations and all(part.get('packages') or part.get('boxes')==1 for part in allocations)):
|
||||
fields.append('各箱重量')
|
||||
dimensions=(intent['dims'] or packages and all(part.get('dims') for part in packages)
|
||||
or allocations and all(part.get('packages') and all(box.get('dims') for box in part['packages']) for part in allocations))
|
||||
if not dimensions:fields.append('每箱长宽高')
|
||||
if intent.get('english'):
|
||||
names={'目的仓码或邮编':'destination warehouse code or ZIP code','箱数':'carton count',
|
||||
'总重或各箱重量':'total weight or each carton weight','各箱重量':'each carton weight','每箱长宽高':'each carton dimensions'}
|
||||
return 'Please provide '+', '.join(names[field] for field in fields)+'.' if fields else None
|
||||
return '请补充'+'、'.join(fields)+',继续核对到仓运费和箱规费用。' if fields else None
|
||||
@@ -15,6 +15,7 @@ MISSING_WEIGHT_UNIT = '请补充修改后重量的单位,例如KG或吨,我
|
||||
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
|
||||
TIMING_QUESTION = re.compile(r'时效|多久|几天|什么时候.*到|哪天.*到|何时.*到')
|
||||
CLAIM_QUESTION = re.compile(r'理赔|索赔|赔(?:偿|付|钱|不赔|吗|嘛|么)|补偿|费用.*谁.*(?:承担|负责)')
|
||||
DIFFERENCE = re.compile(r'差价|相差|差多少|贵多少|(?<!最)便宜多少')
|
||||
|
||||
|
||||
def timing_with_claim(message):
|
||||
@@ -86,7 +87,7 @@ def request_selection(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 DIFFERENCE.search(text) and (fastest or slowest):return 'speed_compare'
|
||||
if cheapest:return 'cheapest'
|
||||
if fastest and slowest:return 'speed_compare'
|
||||
if fastest:return 'fastest'
|
||||
@@ -107,7 +108,8 @@ def request_mode(text):
|
||||
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 DIFFERENCE.search(text):return 'difference'
|
||||
if re.search(r'展开(?:报价)?明细|展开(?:价格|费用)|详细报价|完整报价',text) or re.fullmatch(r'(?:那)?明细(?:呢)?[。.!!??\s]*',text):return 'quote_detail'
|
||||
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'
|
||||
@@ -214,6 +216,7 @@ def parse_intent(message,warehouse_codes):
|
||||
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)
|
||||
from .quote_plans import plan_reference
|
||||
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'],
|
||||
@@ -222,13 +225,14 @@ def parse_intent(message,warehouse_codes):
|
||||
'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)),
|
||||
'compare_services':mode=='difference' or 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),
|
||||
'plan_reference':plan_reference(message),'plan_handled':False,
|
||||
'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)),
|
||||
@@ -241,10 +245,12 @@ def parse_intent(message,warehouse_codes):
|
||||
|
||||
|
||||
def is_followup(message,intent):
|
||||
from .quote_plans import choice_confirmation
|
||||
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('plan_reference') is not None or choice_confirmation(message):return True
|
||||
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'])
|
||||
|
||||
@@ -12,6 +12,7 @@ def translate_quote(text):
|
||||
('休斯顿普船海卡','Houston regular sea truck'),('纽约普船海卡','New York regular sea truck'),
|
||||
('萨凡纳普船海卡','Savannah regular sea truck'),('EXX海卡','EXX sea truck'),('合德海卡','Hede sea truck'),
|
||||
('合德海派','Hede sea parcel'),('普船海派','Regular sea parcel'),
|
||||
('海卡/海铁','sea truck/rail'),('海派','sea parcel'),
|
||||
('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。','Please provide the carton count, weight and dimensions of each carton to calculate chargeable weight and size surcharges.'),
|
||||
('发我目的仓码或邮编,可继续匹配到仓线路。','Send the destination warehouse code or ZIP code to match the route.'),
|
||||
('货物多少公斤、几箱?','What are the total weight and carton count?'),
|
||||
@@ -24,6 +25,11 @@ def translate_quote(text):
|
||||
('暂时无法确定,匹配线路未列明可比较的天数。',' cannot be determined because matching routes have no comparable numeric day estimates.'),
|
||||
('最快参考方案','Fastest reference options'),('最慢参考方案','Slowest reference options'),
|
||||
('最便宜参考方案','Cheapest reference options'),
|
||||
('按当前条件,同一交仓地、区域、计费单位和税费口径比较。','Compared on the current origin, region, billing unit and tax basis.'),
|
||||
('最快与最便宜方案可计算运费差额为','The computable fastest-minus-cheapest freight difference is '),
|
||||
('快慢方案可计算运费差额为','The computable fastest-minus-slowest freight difference is '),
|
||||
('参考时效分别约','Reference transit times are approximately '),
|
||||
('回复“展开报价明细”可查看单价和各方案总价。','Ask for detailed rates to see unit prices and route totals.'),
|
||||
('按资料中的参考时效,较快方案如下;提取不等于送达或签收:','Faster reference options follow. Pickup timing is not a delivery or receipt commitment:'),
|
||||
('该邮编属于遥远地址,派送附加费需单独确认。','This ZIP code is extremely remote; the delivery surcharge requires confirmation.'),
|
||||
('海派已计入偏远附加费','Sea parcel totals include the remote surcharge of '),
|
||||
@@ -34,6 +40,9 @@ def translate_quote(text):
|
||||
('若干','several '),
|
||||
('提取',' until pickup'),('天左右',' days approximately'),('天',' days'),('元/KG',' CNY/kg'),('元/CBM',' CNY/CBM'),
|
||||
('元',' CNY'),(':',': '),(';','; '),(',',', '),('(',' ('),(')',')'))
|
||||
text=re.sub(r'当前按方案(\d+)继续核算。',r'Continuing with option \1.',text)
|
||||
text=re.sub(r'方案(\d+)',r'Option \1',text)
|
||||
text=re.sub(r'按([\d.]+)KG核算。',r'Based on \1KG. ',text)
|
||||
for source,target in replacements:text=text.replace(source,target)
|
||||
text=re.sub(r'([东中西])(\d)区',lambda m:{'东':'East','中':'Central','西':'West'}[m[1]]+' zone '+m[2],text)
|
||||
return text
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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
|
||||
@@ -11,21 +11,28 @@ 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):
|
||||
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 content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,))
|
||||
for (previous,) in recent:
|
||||
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)
|
||||
@@ -40,12 +47,25 @@ def previous_quote_intents(conv_id,codes,resume=False,include_other=False):
|
||||
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
|
||||
@@ -58,7 +78,8 @@ def intent_with_context(message, conv_id, book):
|
||||
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']
|
||||
earlier_intents=(previous_quote_intents(conv_id,codes,resume=resumes_quote(message),include_other=named_us_service)
|
||||
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'])
|
||||
@@ -76,7 +97,13 @@ def intent_with_context(message, conv_id, book):
|
||||
return intent
|
||||
|
||||
|
||||
def answer_quote(message, conv_id):
|
||||
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.
|
||||
@@ -89,6 +116,7 @@ def answer_quote(message, conv_id):
|
||||
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在当前资料中是美国渠道。请确认这票是否发往美国,再继续核对时效和价格。'}
|
||||
|
||||
@@ -1768,6 +1768,7 @@ async function doSend(retryText, showQuestion){
|
||||
attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
|
||||
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
|
||||
renderFollowups(aiBubble);
|
||||
scrollChatToEnd();
|
||||
updateToBottom();
|
||||
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ async function customerPage(pageOptions = {}){
|
||||
getAttribute(name){ return this.attributes[name]; }
|
||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
appendChild(child){
|
||||
child.parent = this; this.children.push(child);
|
||||
if(pageOptions.actionHeight && child.classList.contains('msg-actions')) element('chatDom').scrollHeight+=pageOptions.actionHeight;
|
||||
}
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
|
||||
@@ -317,6 +320,13 @@ test('a local completed reply keeps waiting feedback then progressively renders
|
||||
assert.equal(bubble.querySelector('.retry-btn'),null);
|
||||
});
|
||||
|
||||
test('following the latest reply includes the final action toolbar in the scroll position', async () => {
|
||||
const app=await customerPage({human:false,actionHeight:80});
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
assert.equal(app.element('chatDom').scrollTop,app.element('chatDom').scrollHeight);
|
||||
});
|
||||
|
||||
test('stopping during the waiting stage cancels pending presentation', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
await app.context.window.fillInput('查询报价');
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Mixed-domain answers and displayed quote references preserve scope and facts."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app,db,order_data
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from test_quote_timing import timing_snapshot
|
||||
from test_quotes import frames
|
||||
|
||||
|
||||
class ConversationCompositionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||
'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'3'*64})
|
||||
self.cid=self.client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
import_snapshot(connection,timing_snapshot(),activate=True)
|
||||
order_data.import_records(connection,[
|
||||
{'tenant_id':'a','order_no':'MIXORDER20260001','inbound_code':'MIXIN20260001','status':'PROCESSING',
|
||||
'exception_reason':'等待资料','source_updated_at':'2026-11-02 10:00'},
|
||||
{'tenant_id':'a','order_no':'MIXORDER20260002','inbound_code':'MIXIN20260002','status':'PROCESSING',
|
||||
'source_updated_at':'2026-11-01 10:00'},
|
||||
{'tenant_id':'b','order_no':'SECRETORDER20260001','exception_reason':'SECRET_REASON'},
|
||||
])
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
def login(self):
|
||||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||
|
||||
def ask(self,question):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':self.cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
self.assertNotIn('SECRET',str(events))
|
||||
self.assertNotIn('plan_option',str(events))
|
||||
return events[0]
|
||||
|
||||
def test_guest_gets_public_quote_and_login_prompt_without_order_reads(self):
|
||||
with patch('logistics_chat.order_listing.read_orders') as read:
|
||||
answer=self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快多少钱')
|
||||
read.assert_not_called()
|
||||
self.assertTrue(answer['login_required'])
|
||||
self.assertIn('1000KG = 10000元',answer['t'])
|
||||
self.assertNotIn('MIXORDER',answer['t'])
|
||||
|
||||
def test_customer_gets_both_answers_and_logout_protects_the_combined_history(self):
|
||||
self.login()
|
||||
answer=self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快多少钱')
|
||||
self.assertIn('共有 2 票订单',answer['t'])
|
||||
self.assertIn('1000KG = 10000元',answer['t'])
|
||||
self.assertTrue(self.client.get('/api/get_messages',query_string={'conv_id':self.cid}).json)
|
||||
self.client.post('/api/customer/logout',json={})
|
||||
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':self.cid}).json,[])
|
||||
|
||||
def test_reverse_domain_order_and_followup_quantity_keep_both_results(self):
|
||||
self.login()
|
||||
answer=self.ask('美国1000KG义乌美东海派最快多少钱,顺便我有多少异常订单')
|
||||
self.assertIn('1 票明确标记异常',answer['t'])
|
||||
self.assertIn('1000KG = 10000元',answer['t'])
|
||||
self.assertIn('1100KG = 11000元',self.ask('再加100KG')['t'])
|
||||
|
||||
def test_mixed_reply_requires_domain_for_bare_ordinal_and_supports_explicit_ones(self):
|
||||
self.login()
|
||||
self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
ambiguous=self.ask('第二个呢')['t']
|
||||
self.assertIn('订单',ambiguous)
|
||||
self.assertIn('报价方案2',ambiguous)
|
||||
self.assertEqual(self.ask('第二个订单')['card']['no'],'MIXORDER20260002')
|
||||
quote=self.ask('第二个方案')['t']
|
||||
self.assertIn('1000KG = 6000元',quote)
|
||||
self.assertNotIn('美森正班海派',quote)
|
||||
|
||||
def test_second_displayed_plan_remains_selected_through_acknowledgement_and_edits(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
selected=self.ask('第二个方案呢')['t']
|
||||
self.assertIn('当前按方案2继续核算',selected)
|
||||
self.assertIn('1000KG = 6000元',selected)
|
||||
self.ask('就选这个')
|
||||
self.assertIn('25-30天左右提取',self.ask('多久能提取')['t'])
|
||||
self.assertIn('1100KG = 6600元',self.ask('再加100KG')['t'])
|
||||
first=self.ask('第一个方案')['t']
|
||||
self.assertIn('1100KG = 11000元',first)
|
||||
self.assertNotIn('普船海派',first)
|
||||
|
||||
def test_prompted_plan_number_is_accepted_and_ambiguous_confirmation_is_clarified(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
self.assertIn('方案1、方案2',self.ask('就这个')['t'])
|
||||
selected=self.ask('我选方案2')['t']
|
||||
self.assertIn('1000KG = 6000元',selected)
|
||||
self.assertIn('普船海派',selected)
|
||||
|
||||
def test_priority_change_after_a_numbered_selection_can_switch_carriers(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
self.ask('方案1')
|
||||
text=self.ask('那我想省钱')['t']
|
||||
self.assertIn('普船海派',text)
|
||||
self.assertNotIn('美森正班海派',text)
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
|
||||
def test_english_displayed_options_remain_english(self):
|
||||
self.ask('1000kg to USA by sea, fastest and cheapest prices please')
|
||||
selected=self.ask('Option 2')['t']
|
||||
self.assertIn('Regular sea parcel',selected)
|
||||
self.assertIn('Continuing with option 2',selected)
|
||||
self.assertNotRegex(selected,r'[\u4e00-\u9fff]')
|
||||
|
||||
def test_english_compact_difference_contains_no_chinese_text(self):
|
||||
from logistics_chat.quote_data import load_book
|
||||
from logistics_chat.quote_engine import parse_intent,variants,render_rates
|
||||
with self.app.app_context():
|
||||
book=load_book()
|
||||
intent=parse_intent('1000kg to USA by sea fastest and slowest prices',set())
|
||||
intent['mode']='difference'
|
||||
destinations=variants(intent,book)
|
||||
text=render_rates(intent,book,destinations,[None]*len(destinations))
|
||||
self.assertNotRegex(text,r'[\u4e00-\u9fff]')
|
||||
self.assertIn('4000 CNY',text)
|
||||
|
||||
def test_out_of_range_plan_does_not_choose_a_route(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
text=self.ask('第9个方案')['t']
|
||||
self.assertIn('展示了2个方案',text)
|
||||
self.assertNotIn('KG =',text)
|
||||
|
||||
def test_displayed_choice_preserves_carrier_identity_after_prices_change(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET price='3' WHERE service='美森正班海派'")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
selected=self.ask('第二个方案')['t']
|
||||
self.assertIn('普船海派',selected)
|
||||
self.assertIn('1000KG = 6000元',selected)
|
||||
self.assertNotIn('美森正班海派',selected)
|
||||
|
||||
def test_removed_displayed_carrier_requires_a_new_selection_through_followups(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("DELETE FROM quote_rates WHERE service='普船海派'")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self.assertIn('暂无可用报价',self.ask('方案2')['t'])
|
||||
self.assertIn('暂无可用报价',self.ask('那具体多少钱')['t'])
|
||||
text=self.ask('全部渠道报价')['t']
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertNotIn('暂无可用报价',text)
|
||||
|
||||
def test_new_quote_root_replaces_the_old_plan_catalog(self):
|
||||
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
self.ask('新的一批200KG美国义乌美西海派报价')
|
||||
selected=self.ask('第二个方案')['t']
|
||||
self.assertIn('合德海派',selected)
|
||||
self.assertIn('200KG = 1200元',selected)
|
||||
self.assertNotIn('1000KG',selected)
|
||||
|
||||
def test_legacy_unnumbered_rows_keep_their_positions_after_a_carrier_is_removed(self):
|
||||
from logistics_chat.quote_plans import displayed_options
|
||||
from logistics_chat.quote_data import load_book
|
||||
content='美东:\n- 旧渠道 · 义乌 · 包税:5元/KG\n- 普船海派 · 义乌 · 包税:6元/KG'
|
||||
with self.app.app_context():
|
||||
options=displayed_options(content,load_book())
|
||||
self.assertEqual([option['number'] for option in options],[1,2])
|
||||
self.assertEqual(options[0]['rows'],[])
|
||||
self.assertEqual(options[1]['rows'][0]['service'],'普船海派')
|
||||
|
||||
def test_removed_warehouse_does_not_reuse_the_previous_destination(self):
|
||||
from logistics_chat.quote_plans import displayed_options
|
||||
from logistics_chat.quote_data import load_book
|
||||
content='美东:\n- 普船海派 · 义乌 · 包税:6元/KG\nABC1 · 东1区:\n- 普船海派 · 义乌 · 包税:6元/KG'
|
||||
with self.app.app_context():
|
||||
options=displayed_options(content,load_book())
|
||||
self.assertEqual(options[1]['warehouse'],'ABC1')
|
||||
self.assertTrue(options[1]['destination_unavailable'])
|
||||
|
||||
def test_different_visitor_cannot_use_private_plan_bindings(self):
|
||||
self.login()
|
||||
self.ask('我有多少订单,同时美国1000KG义乌美东海派最快和最便宜多少钱')
|
||||
other=self.app.test_client()
|
||||
other.post('/api/visitor/session',json={'key':'4'*64})
|
||||
with patch('logistics_chat.quote_plans.history_bindings') as read:
|
||||
response=frames(other.post('/stream_chat',json={'msg':'方案2','conv_id':self.cid}))
|
||||
read.assert_not_called()
|
||||
self.assertTrue(any(event.get('e') for event in response))
|
||||
|
||||
def test_known_box_count_and_weights_are_not_requested_again(self):
|
||||
text=self.ask('美国10箱每箱20KG义乌美西普船海派报价')['t']
|
||||
question=text.splitlines()[-1]
|
||||
self.assertIn('每箱长宽高',question)
|
||||
self.assertIn('目的仓码或邮编',question)
|
||||
self.assertNotIn('箱数',question)
|
||||
self.assertNotIn('重量',question)
|
||||
text=self.ask('每箱50x40x30cm')['t']
|
||||
question=text.splitlines()[-1]
|
||||
self.assertNotIn('长宽高',question)
|
||||
self.assertNotIn('重量',question)
|
||||
|
||||
def test_all_known_shipment_fields_produce_no_repeated_followup(self):
|
||||
text=self.ask('美国10箱每箱20KG每箱50x40x30cm,ZZZ1,义乌普船海派报价')['t']
|
||||
self.assertNotIn('请补充',text)
|
||||
|
||||
def test_selected_warehouse_variant_preserves_its_zone_and_assigned_weight(self):
|
||||
snapshot=timing_snapshot()
|
||||
snapshot['source_sha256']='2'*64
|
||||
snapshot['warehouses'].append({**snapshot['warehouses'][0],'code':'ALT1','zone':'西1区','postal':'90001'})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
import_snapshot(connection,snapshot,activate=True)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
self.ask('美国ZZZ1 100KG,ALT1 200KG,义乌海派报价,总体积10CBM')
|
||||
# ZZZ1 has two zones, with three parcel options in each zone.
|
||||
selected=self.ask('方案4')['t']
|
||||
self.assertIn('ZZZ1 · 东2区',selected)
|
||||
self.assertNotIn('东1区',selected)
|
||||
self.assertNotIn('ALT1',selected)
|
||||
self.assertIn('100KG = 1100元',selected)
|
||||
self.assertNotIn('300KG',selected)
|
||||
|
||||
def test_difference_is_short_and_details_expand_without_losing_conditions(self):
|
||||
self.ask('美国1000KG海派最快和最慢报价')
|
||||
text=self.ask('快的贵多少')['t']
|
||||
self.assertLess(len(text),350)
|
||||
self.assertIn('差额为4000元',text)
|
||||
self.assertIn('15天、25–30天',text)
|
||||
self.assertNotIn('1000KG =',text)
|
||||
details=self.ask('展开报价明细')['t']
|
||||
self.assertIn('1000KG = 10000元',details)
|
||||
self.assertIn('1000KG = 6000元',details)
|
||||
@@ -63,7 +63,7 @@ class CustomerGapTests(unittest.TestCase):
|
||||
text=self.ask(q,cid)
|
||||
self.assertNotIn('21KG',text);self.assertNotIn('250',text);self.assertNotIn('7元/KG',text)
|
||||
text=self.ask('我想发点货到美国,怎么收费?')
|
||||
self.assertIn('元/KG',text);self.assertIn('多少公斤',text)
|
||||
self.assertIn('元/KG',text);self.assertIn('总重或各箱重量',text)
|
||||
|
||||
def test_final_total_respects_tax_labels_and_keeps_unconfirmed_fees_separate(self):
|
||||
cid=self.conversation();self.ask('美国美西2箱每箱15KG,40x30x20cm,义乌普船海派',cid)
|
||||
|
||||
@@ -133,10 +133,12 @@ class QuoteChangeTests(unittest.TestCase):
|
||||
text=self.ask('那比慢的贵多少')
|
||||
self.assertIn('运费差额为4000元',text)
|
||||
|
||||
def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self):
|
||||
def test_numbered_plan_reference_selects_the_displayed_slow_option(self):
|
||||
self.ask('美国1000KG海运最快和最慢报价')
|
||||
self.assertIn('1000KG',self.ask('这个多少钱'))
|
||||
self.assertIn('请指定方案名称',self.ask('第二个方案呢'))
|
||||
selected=self.ask('第二个方案呢')
|
||||
self.assertIn('普船海派',selected)
|
||||
self.assertNotIn('美森正班海派',selected)
|
||||
self.assertIn('1000KG = 4000元',self.ask('便宜的那个'))
|
||||
self.assertIn('1000KG = 4000元',self.ask('一共呢'))
|
||||
|
||||
@@ -184,9 +186,12 @@ class QuoteChangeTests(unittest.TestCase):
|
||||
c.execute("UPDATE quote_rates SET eta='船开后16天左右' WHERE service='美森正班海派' AND origin='华南'")
|
||||
c.commit();c.close()
|
||||
text=self.ask('美国1000KG美东海派最快最慢差价')
|
||||
self.assertIn('美森正班海派 · 义乌',text)
|
||||
self.assertIn('美森正班海派 · 华南',text)
|
||||
self.assertIn('义乌',text)
|
||||
self.assertIn('华南',text)
|
||||
self.assertEqual(text.count('运费差额为4000元'),2)
|
||||
detail=self.ask('展开报价明细')
|
||||
self.assertIn('美森正班海派 · 义乌',detail)
|
||||
self.assertIn('美森正班海派 · 华南',detail)
|
||||
|
||||
def test_volume_increments_keep_the_confirmed_total_and_units(self):
|
||||
self.ask('美国2CBM义乌美东普船海派报价')
|
||||
|
||||
@@ -221,10 +221,10 @@ class QuoteConversationTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.ask('美国3000KG义乌美东海派报价')
|
||||
text=self.ask('最快和最便宜相差多少钱')
|
||||
cheap=text.split('最便宜参考方案')[1]
|
||||
self.assertIn('合德海派',cheap)
|
||||
self.assertIn('最快与最便宜方案可计算运费差额为21000元',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
detail=self.ask('展开报价明细')
|
||||
self.assertIn('合德海派',detail.split('最便宜参考方案')[1])
|
||||
|
||||
def test_english_fastest_and_cheapest_have_both_headings(self):
|
||||
text=self.ask('3000kg to USA by sea, fastest and cheapest prices please')
|
||||
|
||||
@@ -96,7 +96,7 @@ class QuoteTests(unittest.TestCase):
|
||||
self.assertIn('美国 30KG',text)
|
||||
fresh = self.ask('ZZZ1')
|
||||
self.assertNotIn('美国 30KG',fresh)
|
||||
self.assertIn('多少公斤',fresh)
|
||||
self.assertIn('总重或各箱重量',fresh)
|
||||
|
||||
def test_new_topics_do_not_reuse_old_quote_or_cross_order_privacy(self):
|
||||
from logistics_chat.quote_service import answer_quote
|
||||
|
||||
Reference in New Issue
Block a user