完善混合提问与报价连续追问

This commit is contained in:
2026-11-06 16:02:46 +08:00
parent 2409bb5c36
commit afe2bd1207
19 changed files with 658 additions and 52 deletions
+1
View File
@@ -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` |
+2
View File
@@ -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`,下层不导入路由或根入口。
+35
View File
@@ -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']}
+7 -1
View File
@@ -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)
+58 -8
View File
@@ -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
+13 -1
View File
@@ -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'):
+37 -25
View File
@@ -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
+23
View File
@@ -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
+9 -3
View File
@@ -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'])
+9
View File
@@ -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
+164
View File
@@ -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
+33 -5
View File
@@ -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在当前资料中是美国渠道。请确认这票是否发往美国,再继续核对时效和价格。'}
+1
View File
@@ -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(); // 首条消息会让标题变成问题内容,这里刷新一下
}
+11 -1
View File
@@ -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('查询报价');
+242
View File
@@ -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)
+1 -1
View File
@@ -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)
+9 -4
View File
@@ -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义乌美东普船海派报价')
+2 -2
View File
@@ -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')
+1 -1
View File
@@ -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