修复数量增减及前后票连续追问

This commit is contained in:
2026-11-06 13:35:02 +08:00
parent bcfccc7013
commit a72d7f4d91
14 changed files with 696 additions and 38 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `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` |
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals and subset questions; `order_tracking.py`: `current_stage` |
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions and quantity adjustments; `quote_comparison.py`: comparable fast/slow costs; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals, adjacent rows and subset questions; `order_tracking.py`: `current_stage` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
+2
View File
@@ -80,6 +80,8 @@ tests/ # 按业务模块组织的回归
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
+11 -5
View File
@@ -23,7 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipmen
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
'parent':'父订单号', 'updated':'来源更新时间'}
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
ORDER_DETAIL = r'状态|进度|轨迹|异常|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
ORDER_DETAIL = r'状态|进度|轨迹|异常|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|多重|重多少|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
@@ -138,7 +138,7 @@ def tenant_filter(tenant_id=None, tenant_ids=None):
return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', [])
def lookup_order(reference, tenant_id=None, tenant_ids=None):
def lookup_order(reference, tenant_id=None, tenant_ids=None, *, exact_order=False):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect
@@ -149,8 +149,9 @@ def lookup_order(reference, tenant_id=None, tenant_ids=None):
try:
connection = connect()
connection.row_factory = sqlite3.Row
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0]
values = [reference] * 3 + scope[1]
condition='order_no=?' if exact_order else '(order_no=? OR inbound_code=? OR shipment_id=?)'
query = 'SELECT * FROM order_test_data WHERE ' + condition + scope[0]
values = [reference] * (1 if exact_order else 3) + scope[1]
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
except sqlite3.Error:
raise OrderDataUnavailable from None
@@ -332,6 +333,7 @@ def is_order_followup(message):
def answer_query(message, conv_id):
references = extract_reference(message)
exact_order = False
tenants = customer_tenant_ids()
from .order_explanations import general_explanation, detail_question, explain_order
if not references:
@@ -345,6 +347,7 @@ def answer_query(message, conv_id):
if followup is not None:
if '_order_reference' not in followup:return followup
references=[followup['_order_reference']]
exact_order=True
count_intent = parse_count_query(message) if not references else None
if count_intent is not None:
return answer_count_query(count_intent,tenants,conv_id)
@@ -377,6 +380,7 @@ def answer_query(message, conv_id):
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
exact_order=True
break
except (ValueError, KeyError, TypeError):
pass
@@ -399,7 +403,9 @@ def answer_query(message, conv_id):
if not tenants:
return order_login_response()
try:
matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
scope={'tenant_id':tenants[0]} if len(tenants)==1 else {'tenant_ids':tenants}
if exact_order:scope['exact_order']=True
matches = lookup_order(reference,**scope)
except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if not matches:
+7 -1
View File
@@ -4,9 +4,12 @@ import re
def detail_question(message):
from .quote_edits import quantity_adjustment
if quantity_adjustment(message):
return False
if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message):
return False
return bool(re.search(r'异常原因|为什么异常|什么异常|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
return bool(re.search(r'异常原因|为什么异常|什么异常|多重|重多少|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|'
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
@@ -25,6 +28,9 @@ def explain_order(message,row,card):
if re.search(r'异常原因|为什么异常|什么异常',message):
reason=(row.get('exception_reason') or '').strip()
return lines[0]+('已记录的异常原因:'+reason if reason else '当前未登记异常原因,请联系人工核实。')
if re.search(r'多重|重多少',message):
weight=row.get('gross_weight_kg')
return lines[0]+(f'登记的申报毛重为{weight}KG。' if weight is not None else '当前未登记申报毛重,请联系人工核对重量。')
if re.search(r'催单|催一下|拦截',message):
return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。'
if re.search(r'付过款|付款状态',message):
+46 -4
View File
@@ -1,5 +1,6 @@
"""Resolve bounded list references and subset questions from scoped saved replies."""
import json
import re
import sqlite3
@@ -30,6 +31,16 @@ def ordinal_request(message):
return number
def list_step(message):
from .order_data import is_order_followup
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:那|再看|看看|查一下|查询)?(下一|后一|上一|前一)'
r'(?:票|个|条|单)(?:订单|运单)?(?:的)?(.*)',text)
if not match:return None
if match[2] not in ('','呢','详情') and not is_order_followup(match[2]):return None
return 1 if match[1] in ('下一','后一') else -1
def shown_references(connection,conv_id,state):
if 'shown' in state:
return [(item['inbound_code'] or '未登记',item['order_no']) for item in state['shown']]
@@ -46,12 +57,29 @@ def shown_references(connection,conv_id,state):
return result
def selected_row(content,shown):
from .tracking import CARD_PREFIX
try:
card=json.loads(content[len(CARD_PREFIX):])
if card.get('kind')!='order' or not isinstance(card.get('no'),str):return None
matches=[index for index,(_,number) in enumerate(shown,1) if number.casefold()==card['no'].casefold()]
if len(matches)>1:
fields=card.get('fields')
if not isinstance(fields,list):return None
inbound=next((field.get('value') for field in fields if isinstance(field,dict) and field.get('key')=='inbound'),None)
matches=[index for index in matches if shown[index-1][0]==inbound]
return matches[0] if len(matches)==1 else None
except (ValueError,TypeError,AttributeError):
return None
def answer_followup(message,tenants,conv_id):
from .customer_service import order_login_response
from .order_listing import load_state,answer_summary
text=message.strip(' ,,。.!!??')
ordinal=ordinal_request(text)
step=list_step(text)
relative=is_relative_question(text)
reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?'
r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text))
@@ -62,10 +90,22 @@ def answer_followup(message,tenants,conv_id):
short_count=(not re.search(NOUN,text,re.I) and not re.search(COUNT,text,re.I)
and intent is not None and not intent['unsupported']
and any(intent.get(key) for key in ('status','stage','exception','grouped')))
if ordinal is None and not relative and not reason and not short_count:return None
if ordinal is None and step is None and not relative and not reason and not short_count:return None
if not tenants:return order_login_response()
try:
previous=load_state(conv_id)
if step is not None:
if not previous:return {'t':'请先查看订单列表并选择一票,或直接提供入仓码。'}
selected=previous.get('selected_index')
if selected is None:return {'t':'请先在当前列表中选择一票,例如“第一个”,再继续查看上一票或下一票。'}
connection=connect()
try:shown=shown_references(connection,conv_id,previous)
finally:connection.close()
ordinal=selected+step
if not 1<=ordinal<=len(shown):
command='下一页' if step>0 else '上一页'
edge='最后一票' if step>0 else '第一票'
return {'t':f'当前展示列表已到{edge}。可回复“{command}”翻页,或提供入仓码查询其他订单。'}
if ordinal is not None:
if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'}
connection=connect()
@@ -75,9 +115,11 @@ def answer_followup(message,tenants,conv_id):
if not shown:return {'t':'当前列表没有可查询的订单,请重新指定筛选条件,或直接提供入仓码。'}
if not 1<=ordinal<=len(shown):
return {'t':f'刚才的列表展示了{len(shown)}票,请选择其中一票,或回复“查看全部”展开列表后再查询。'}
inbound,number=shown[ordinal-1]
return {'_order_reference':number if inbound=='未登记' else inbound}
if short_count and not relative and (not previous or previous.get('selected')):return None
return {'_order_reference':shown[ordinal-1][1]}
if short_count and not relative and (not previous or previous.get('selected')):
from .order_data import is_order_followup
if is_order_followup(text):return None
return {'t':'请先说明要统计的订单范围,例如“我的异常订单有多少”,或提供入仓码查询单票情况。'}
if reason:
if not previous or previous.get('selected'):return None
intent={**previous['query'],'exception':True,'unsupported':False}
+14 -4
View File
@@ -31,8 +31,9 @@ def load_state(conv_id):
from .customer_service import current_customer_id,current_scope_key
from .visitor_service import visitor_id
from .customer_faq import is_acknowledgement
from .order_followups import ordinal_request,shown_references,is_relative_question
from .order_followups import ordinal_request,list_step,shown_references,selected_row,is_relative_question
from .order_data import is_order_followup
from .tracking import CARD_PREFIX
if not conv_id:return None
connection=connect()
try:
@@ -51,13 +52,22 @@ def load_state(conv_id):
or item['inbound_code'] is not None and not isinstance(item['inbound_code'],str) for item in shown):
return None
state['selected']=False
for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id",
(conv_id,state['message_id'])):
state['selected_index']=None
visible=shown_references(connection,conv_id,state)
for role,text in connection.execute('SELECT role,content FROM messages WHERE conv_id=? AND id>? ORDER BY id',
(conv_id,state['message_id'])):
if role!='user':
if role=='assistant' and text.startswith(CARD_PREFIX):
state['selected_index']=selected_row(text,visible)
state['selected']=state['selected_index'] is not None
continue
if is_acknowledgement(text):continue
if is_relative_question(text):continue
ordinal=ordinal_request(text)
if ordinal is not None:
state['selected']=1<=ordinal<=len(shown_references(connection,conv_id,state))
continue
step=list_step(text)
if step is not None:
continue
if state['selected'] and is_order_followup(text):continue
if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'):
+34
View File
@@ -0,0 +1,34 @@
"""Compare calculable CNY costs on the same destination and billing basis."""
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
groups = {}
for rate in rates:
key = (rate['origin'], rate['zone'], rate['kind'], rate['unit'], rate['tax'])
groups.setdefault(key, []).append(rate)
lines = []
for (origin, zone, kind, unit, tax), candidates in groups.items():
costs = []
for selected in (select_timed_rates(candidates), select_timed_rates(candidates, slowest=True)):
values = []
for rate in selected:
amount = quantity(intent, rate)
if amount is not None:
total=amount * Decimal(rate['price']) + charge_details(intent, rate, fees, book)['cny']
values.append(total.quantize(Decimal('.01'),rounding=ROUND_HALF_UP))
costs.append(values)
fast, slow = costs
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)
lines.append(f'- {label},快慢方案可计算运费差额为{difference}元。')
return lines
+147 -5
View File
@@ -4,27 +4,167 @@ from copy import deepcopy
from decimal import Decimal
from .quote_intent import kg,MISSING_WEIGHT_UNIT
from .quote_edits import ADJUSTMENT_QUESTION,VOLUME_QUESTION
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
def apply_quantity_change(result,current,previous):
from .quote_engine import exact_number
change=current.get('quantity_change')
if not change:return False
if change['kind']=='volume':
updated=Decimal(current['cbm']) if current['cbm'] is not None else None
mismatch=False
if not change['uncertain'] and previous['cbm'] is not None and not previous.get('volume_pending'):
expected=Decimal(previous['cbm'])+Decimal(change['delta'])
if updated is not None and updated!=expected:
mismatch=True
result['errors'].append('增减体积与您提供的新总体积不一致,请确认调整后的总体积。')
else:updated=expected
if change['uncertain'] or updated is None or updated<=0 or mismatch:
result['volume_pending']=True
result['errors'].append(VOLUME_QUESTION)
else:
result['cbm']=str(updated)
result['volume_pending']=False
result['quantity_note']=f'按本次增减更新,总体积为{exact_number(updated)}CBM。'
return False
question=ADJUSTMENT_QUESTION
result['quantity_question']=question
result['quantity_uncertain']=True
if change['kind']=='boxes' and (previous['cbm'] is not None or previous.get('volume_pending') or current['cbm'] is not None):
result['volume_pending']=True
if change['uncertain'] or previous.get('allocations'):
result['errors'].append(question)
return False
if change['kind']=='weight':
if previous.get('weight') is None or previous.get('quantity_uncertain') or previous.get('pending_weight'):
if current['weight'] is None:
result['errors'].append(question)
return False
updated=Decimal(current['weight'])
else:
updated=Decimal(previous['weight'])+Decimal(change['delta'])
if current['weight'] is not None and Decimal(current['weight'])!=updated:
result['errors'].append('增减量与您提供的新总重不一致,请确认调整后的总重。')
return False
if updated<=0:
result['errors'].append('调整后的总重需要大于0,请确认增减量或新的总重。')
return False
result['weight']=str(updated)
else:
pieces=deepcopy(previous.get('packages') or [])
if not pieces or previous.get('quantity_uncertain') or previous.get('pending_weight'):
result['errors'].append(question)
return False
for piece in pieces:
if not piece.get('dims') and previous['dims']:
piece.update(dims=previous['dims'],dim_unit=previous['dim_unit'])
first=pieces[0]
uniform=all(Decimal(piece['weight'])==Decimal(first['weight'])
and all(piece.get(key)==first.get(key) for key in ('dims','dim_unit')) for piece in pieces)
if uniform:
first['count']=sum(piece['count'] for piece in pieces)
pieces=[first]
delta=change['delta']
additions=deepcopy(current.get('packages') or [])
target_count=sum(piece['count'] for piece in pieces)+delta
declared_count=change.get('declared_boxes')
if declared_count is not None and declared_count!=target_count:
result['errors'].append('增减箱数与您提供的新总箱数不一致,请确认调整后的箱数。')
return False
if declared_count is not None and additions and sum(piece['count'] for piece in additions)==target_count:
pieces=additions
elif delta>0 and additions:
if sum(piece['count'] for piece in additions)!=delta:
result['errors'].append(question)
return False
pieces.extend(additions)
elif uniform:
if delta<0 and additions and any(Decimal(piece['weight'])!=Decimal(first['weight'])
or piece.get('dims') and (piece['dims']!=first.get('dims') or piece['dim_unit']!=first.get('dim_unit')) for piece in additions):
result['errors'].append('减少箱子的重量与原分箱明细不一致,请提供更新后的逐箱明细。')
return False
first['count']+=delta
if first['count']<=0:
result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。')
return False
else:
result['errors'].append('原货物包含不同箱规,请提供更新后的逐箱数量和重量。')
return False
result['packages']=pieces
result['boxes']=sum(piece['count'] for piece in pieces)
result['weight']=str(sum(Decimal(piece['weight'])*piece['count'] for piece in pieces))
result['dims']=None
result['cbm']=None
if result['boxes']<=0 or any(piece['count']<=0 for piece in pieces):
result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。')
return False
if change.get('declared_weight') is not None and Decimal(change['declared_weight'])!=Decimal(result['weight']):
result['errors'].append('增减箱数推算的总重与您提供的新总重不一致,请确认更新后的分箱明细。')
return False
result['quantity_uncertain']=False
result['quantity_question']=None
verb='增加' if Decimal(change['delta'])>0 else '减少'
delta=exact_number(abs(Decimal(change['delta'])))
if change['kind']=='boxes':
old_count=sum(piece['count'] for piece in previous['packages'])
result['quantity_note']=f'按原{old_count}箱{verb}{delta}箱核算,共{result["boxes"]}箱。'
elif previous['weight'] is not None and not previous.get('quantity_uncertain'):
result['quantity_note']=f'按原总重{exact_number(previous["weight"])}KG{verb}{delta}KG核算。'
else:result['quantity_note']='按您确认的新总重核算。'
return True
def merge_intent(current,previous):
result=deepcopy(current)
excluded={'mode','selection','clear_selection','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'}
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'}
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)
weight_change=apply_quantity_change(result,current,previous) or weight_change
change=current.get('quantity_change')
if change and change['kind']=='boxes':excluded.update(('dims','cbm','allocations'))
if change and change['kind']=='volume':excluded.add('dims')
volume_confirmed=not (change and change['kind']=='boxes') and (current['cbm'] is not None or current['dims']
or current.get('packages') and all(piece.get('dims') for piece in current['packages']))
if previous.get('volume_pending') and not volume_confirmed:result['volume_pending']=True
if result.get('volume_pending'):
result['cbm']=None
excluded.add('cbm')
result['errors'].append(VOLUME_QUESTION)
if unit:
if pending is None:result['errors'].append('请同时提供重量数值和单位,例如800KG。')
else:result['weight']=str(kg(pending,unit))
elif pending is not None and current['weight'] is None and current.get('pending_weight') is None:
result['pending_weight']=pending
result['errors'].append(MISSING_WEIGHT_UNIT)
if not current.get('selection') and not current.get('clear_selection'):
if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice'):
result['selection']=previous.get('selection')
if current.get('clear_selection'):
excluded.update(('services','kinds','transport','air_only','air_requested'))
if current.get('compare_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'):
excluded.add('services')
if any(name in ('海派','海卡','海铁') for name in current['excluded_services']):excluded.add('kinds')
positives=(current.get('services') or [])+[name for name in ('海派','海卡','海铁') if name in current['text']]
result['excluded_services']=[name for name in blocked if not any(name in choice or choice in name for choice in positives)] or None
if current.get('tax_pending') or previous.get('tax_pending') and current['tax'] is None:
result['tax_pending']=True
result['tax']=None
excluded.add('tax')
result['errors'].append('请明确选择包税或不包税方案。')
if current.get('choice_pending') or (previous.get('choice_pending') and not current.get('selection')
and not current.get('services') and not current.get('clear_selection')):
result['choice_pending']=True
result['selection']=None
result['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。')
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
result['metal_chargeable_weight']=None
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
@@ -56,13 +196,14 @@ def merge_intent(current,previous):
if current['dims'] is None and 'dims' not in excluded and previous['dims']:
result['dim_unit']=previous['dim_unit']
pieces=result.get('packages') or []
if current['dims']:
if current['dims'] and not (change and change['kind']=='boxes'):
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
elif current['cbm'] is not None:
elif (current['cbm'] is not None or change and change['kind']=='volume') and not (change and change['kind']=='boxes'):
for piece in pieces:piece.update(dims=None,dim_unit='cm')
if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'):
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
result['quantity_question']=previous.get('quantity_question') or QUANTITY_QUESTION
result['errors'].append(result['quantity_question'])
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
if current.get('preserve_total'):
result['packages']=None
@@ -74,4 +215,5 @@ def merge_intent(current,previous):
result['packages']=None
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
result['errors']=list(dict.fromkeys(result['errors']))
return result
+75 -4
View File
@@ -3,11 +3,82 @@
import re
SERVICE_TERMS = ('美森正班','美森加班','美森','EXX','合德','普船','海派','海卡','海铁')
CHANGE_VERBS = r'增加|追加|加上|加|减少|减掉|减去|减|去掉'
ADJUSTMENT_QUESTION = '请提供调整后的总重和箱数,或逐箱重量明细,我再继续核算。'
VOLUME_QUESTION = '请确认调整后的总体积,或提供更新后的逐箱尺寸,我再核算体积重。'
def condition_edits(text):
rejected = r'(?:不是|不要|并非|不从|不走|别走|不用|不选|别选)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到|走|选|用|按)?\s*'
service = r'(?:(?:洛杉矶|纽约|芝加哥|休斯顿|萨凡纳)?(?:美森正班|美森加班|美森|EXX|合德|普船)(?:海派|海卡|海铁|全卡)?|海派|海卡|海铁)'
value = (service+r'|不包税|包税|最快|最慢|最便宜|'
r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|'
r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品')
excluded = []
taxes = set()
clear_choice = False
def discard(match):
nonlocal clear_choice
if re.match(r'\s*(?:的)?(?:吗|嘛|么|[??])', text[match.end():]):
return match[0]
condition = match['value']
term = next((name for name in SERVICE_TERMS if name.lower() in condition.lower()), None)
if term:
excluded.append(term)
if condition in ('包税','不包税'):
taxes.add(condition)
if condition in ('最快','最慢','最便宜'):
clear_choice = True
return ' '
cleaned = re.sub(rejected+'(?P<value>'+value+')', discard, text, flags=re.I)
tax = 'included' if taxes == {'不包税'} else 'excluded' if taxes == {'包税'} else None
return {'text':cleaned, 'excluded_services':list(dict.fromkeys(excluded)) or None,
'tax':tax, 'tax_pending':len(taxes)>1, 'clear_choice':clear_choice}
def clean_conditions(text):
rejected=r'(?:不是|不要|并非|不从)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到)?\s*'
value=(r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|'
r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品')
return re.sub(rejected+'(?:'+value+')',' ',text,flags=re.I)
return condition_edits(text)['text']
def quantity_adjustment(text):
"""Separate a relative increment from an explicitly supplied new total."""
from .quote_intent import WEIGHT,kg,normalize
text=normalize(text)
prefix = re.match(r'^(?:(?:那(?:么)?|我(?:们)?(?:想|要)?|这次|现在|请帮我|帮我|麻烦|请|总重|重量|箱数|总体积|体积)[,,\s]*)*'
r'(?:再)?('+CHANGE_VERBS+r')\s*', text)
if not prefix:
unmatched=re.search(r'(?:'+CHANGE_VERBS+r')\s*-?\d+(?:\.\d+)?\s*(KG|公斤|千克|吨|箱|件|CBM|立方米|立方|方)',text,re.I)
if unmatched:
kind='volume' if unmatched[1].lower() in ('cbm','立方米','立方','方') else 'unknown'
return {'kind':kind,'delta':None,'text':text,'uncertain':True}
return None
subtract = prefix[1] in ('减少','减掉','减去','减','去掉')
sign = -1 if subtract else 1
weight = WEIGHT.match(text, prefix.end())
boxes = re.match(r'(\d+)\s*(?:个)?(?:箱|件)', text[prefix.end():])
volume = re.match(r'(-?\d+(?:\.\d+)?)\s*(?:CBM|立方米|立方|方)',text[prefix.end():],re.I)
if weight:
remainder = text[weight.end():]
return {'kind':'weight', 'delta':str(sign*kg(*weight.groups())),
'text':remainder, 'uncertain':kg(*weight.groups())<=0 or bool(re.search(CHANGE_VERBS+r'\s*\d',remainder))}
if boxes:
total_weight=re.search(r'(?:新总重|总重量|总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
total_boxes=re.search(r'(?:一共|总共|合计|总计|共)\s*(\d+)\s*(?:箱|件)|总箱数\s*(?:是|为)?\s*(\d+)',text)
return {'kind':'boxes', 'delta':sign*int(boxes[1]), 'text':text, 'uncertain':int(boxes[1])<=0,
'declared_weight':str(kg(*total_weight.groups())) if total_weight else None,
'declared_boxes':int(total_boxes[1] or total_boxes[2]) if total_boxes else None}
if volume:
from decimal import Decimal
amount=Decimal(volume[1])
return {'kind':'volume','delta':str(sign*amount),'text':text[prefix.end()+volume.end():],
'uncertain':amount<=0}
if re.match(r'-?\d', text[prefix.end():]):
return {'kind':'unknown', 'delta':None, 'text':text, 'uncertain':True}
return None
def weight_comparison(text):
+16 -3
View File
@@ -93,6 +93,8 @@ def match_rates(intent, book, variant):
continue
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
continue
if any(name in rate['service'] for name in intent.get('excluded_services') or []):
continue
if intent['tax'] and rate['tax'] != intent['tax']:
continue
if rate['kind'] == 'truck':
@@ -127,7 +129,7 @@ def quantity_number(value,intent):
return '约'+number(value,6)
def select_timed_rates(rates,slowest=False):
def select_timed_rates(rates,slowest=False,by_origin=False):
"""Compare numeric day ranges within one region, service kind and billing basis."""
ranked=[]
best={}
@@ -138,6 +140,7 @@ def select_timed_rates(rates,slowest=False):
high=Decimal(match[2] or match[1])
if low<=0 or high<low:continue
key=(rate['zone'],rate['kind'],rate['unit'],rate['tax'])
if by_origin:key+=(rate['origin'],)
timing=(high,low) if slowest else (low,high)
ranked.append((rate,key,timing))
choose=max if slowest else min
@@ -191,9 +194,9 @@ def render_rates(intent, book, destinations, remote_fees):
if rate_selection in ('fastest','slowest','speed_compare'):
selections=[]
if rate_selection in ('fastest','speed_compare'):
selections.append(('最快参考方案',select_timed_rates(rates)))
selections.append(('最快参考方案',select_timed_rates(rates,by_origin=intent.get('mode')=='difference')))
if rate_selection in ('slowest','speed_compare'):
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True)))
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True,by_origin=intent.get('mode')=='difference')))
lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
grouped = {}
for selection,selected_rates in selections:
@@ -232,6 +235,14 @@ def render_rates(intent, book, destinations, remote_fees):
parts.append(rate_text)
lines.append(f"- {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:
lines.append('以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去最慢方案。')
lines.extend(differences)
else:
lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。')
if fees:
if fees['fee'] is None:
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
@@ -241,6 +252,8 @@ def render_rates(intent, book, destinations, remote_fees):
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']:
+27 -11
View File
@@ -52,8 +52,9 @@ def kg(value,unit):
def request_selection(text):
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
fastest=bool(re.search(r'最快|快的|fastest',text,re.I))
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text) and re.search(r'最快|最慢|快的|慢的',text):return 'speed_compare'
if re.search(r'最便宜|便宜的|便宜一?点|更便宜|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
fastest=bool(re.search(r'最快|快的|更快|快一?点|fastest',text,re.I))
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
if fastest and slowest:return 'speed_compare'
if fastest:return 'fastest'
@@ -74,25 +75,30 @@ 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 'total'
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text):return 'difference'
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'
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
if re.search(r'附加费|含税|含派送|全包|包不包|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
if re.search(r'时效|多久|几天|多快(?:能|会)?到',text) and not WEIGHT.search(text):return 'time'
if re.search(r'时效|多久|几天|多快(?:能|会)?到|什么时候(?:能|会|可以)?到|哪天|几号|何时(?:到|能到)|能收到',text) and not WEIGHT.search(text):return 'time'
return request_selection(text) or 'quote'
def parse_intent(message,warehouse_codes):
text=normalize(message)
from .quote_edits import clean_conditions
text=clean_conditions(text)
from .quote_edits import condition_edits,quantity_adjustment,ADJUSTMENT_QUESTION,VOLUME_QUESTION
edits=condition_edits(text)
text=edits['text']
text=re.sub(r'(?:而?不是|并非|不要按|不是按)\s*(?:每[箱件]|单[箱件])?\s*'+WEIGHT.pattern,' ',text,flags=re.I)
from .quote_material import material_intent
materials=material_intent(text,WEIGHT,kg)
quantity_text=materials['quantity_text']
change=quantity_adjustment(quantity_text)
if change:quantity_text=change['text']
parsed=parse_shipping_intent(quantity_text)
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
@@ -130,12 +136,13 @@ def parse_intent(message,warehouse_codes):
if packages:
package_count=sum(p['count'] for p in packages)
aggregate_count=boxes and not any(m.start()<=boxes.start()<m.end() for m in matches)
if count and aggregate_count and count!=package_count:
if count and aggregate_count and count!=package_count and not (change and change['kind']=='boxes'):
errors.append('箱数与分箱明细不一致,请确认总箱数及每箱重量。')
count=package_count
weight=str(sum(Decimal(p['weight'])*p['count'] for p in packages))
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
if total and kg(*total.groups())!=Decimal(weight) and not (change and change['kind']=='boxes'):
errors.append('总重与分箱重量合计不一致,请确认。')
# Per-carton dimensions must not spill onto another carton without dimensions.
if len(packages)>1:parsed['dims']=None
elif assigned:
@@ -158,7 +165,7 @@ def parse_intent(message,warehouse_codes):
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
if match:postal=match.group(1)
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',quantity_text,re.I)
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
@@ -179,7 +186,14 @@ def parse_intent(message,warehouse_codes):
'air_only':True if air and not sea else None,'air_requested':True if air else None,
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
'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 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)),
'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),
'pending_weight':pending_weight[1] if pending_weight else None,
'weight_unit':weight_unit[1] if weight_unit else None,
@@ -194,9 +208,11 @@ def parse_intent(message,warehouse_codes):
def is_followup(message,intent):
if resumes_quote(message):return True
if intent.get('weight_unit'):return True
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
if TOPIC_BOUNDARY.search(message):return False
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'])
text=message.strip().strip(',,。.!!?? ')
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
+18
View File
@@ -55,6 +55,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)
earlier_intents=previous_quote_intents(conv_id,codes,resume=resumes_quote(message)) if is_followup(message,intent) else []
if intent.get('quantity_change') and not earlier_intents:
intent['errors'].append(intent['quantity_question'])
if intent.get('metal_weight_update') and not earlier_intents:
intent['metal_chargeable_weight']=None
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
@@ -83,15 +85,31 @@ def answer_quote(message, conv_id):
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if intent.get('quantity_change'):
if intent['quantity_change']['kind']=='volume':
return {'t':'请先提供原来的总体积和目的国家、目的仓,再说明增减量。'}
return {'t':'请先提供原来的总重、箱数和目的国家,再说明增减量。'}
if resumes_quote(message):
return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'}
if intent.get('mode')=='clarify_route':
return {'t':'请先提供目的国家、重量和所需渠道,再继续核对具体方案。'}
if intent.get('weight_unit'):
return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'}
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None
if intent.get('tax_pending') and '请明确选择包税或不包税方案。' not in intent['errors']:
intent['errors'].append('请明确选择包税或不包税方案。')
if intent.get('choice_pending') and '请指定希望使用的渠道,或选择最快、最慢、最便宜方案。' not in intent['errors']:
intent['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。')
if intent.get('errors'):
return {'t':'\n'.join(intent['errors'])}
if intent.get('mode')=='clarify_route':
return {'t':'请指定方案名称,例如美森正班或普船,或说“最快”“最慢”“最便宜”,我按当前货物条件继续核算。'}
if intent.get('mode')=='difference':
if intent.get('selection') not in ('fastest','slowest','speed_compare'):
return {'t':'请明确要比较的方案,可直接问“最快和最慢相差多少”,我按当前货物条件核算差额。'}
intent['selection']='speed_compare'
if intent.get('unknown_warehouses'):
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
+84
View File
@@ -263,6 +263,90 @@ class OrderStatisticsTests(unittest.TestCase):
self.ask('我有多少异常订单',cid)
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],'STRUCTURED20260001')
def test_adjacent_order_navigation_keeps_the_displayed_rows_and_current_selection(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
text=self.ask('我有多少异常订单',cid)['t']
shown=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],shown[0][1])
self.assertEqual(self.ask('下一票呢',cid)['card']['no'],shown[1][1])
self.ask('谢谢',cid)
self.assertEqual(self.ask('上一票的轨迹',cid)['card']['no'],shown[0][1])
self.assertIn('未登记申报毛重',self.ask('那这个有多重',cid)['t'])
self.assertEqual(self.ask('下一票的轨迹',cid)['card']['no'],shown[1][1])
def test_adjacent_navigation_stops_at_visible_edges_and_resets_on_page_changes(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
c=db.connect()
order_data.import_records(c,[{'tenant_id':'a','order_no':f'PAGEX2026{i:04}','inbound_code':f'PAGEIN2026{i:04}',
'status':'PROCESSING'} for i in range(30)])
c.commit();c.close()
self.ask('列出我的全部订单',cid)
self.ask('第20票的轨迹',cid)
self.assertIn('最后一票',self.ask('下一票呢',cid)['t'])
text=self.ask('下一页',cid)['t']
first=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)[0][1]
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],first)
self.assertIn('第一票',self.ask('上一票呢',cid)['t'])
def test_adjacent_navigation_needs_an_owned_list_and_a_selected_row(self):
for question in ('下一票呢','上一票的轨迹'):
self.assertTrue(self.ask(question)['login_required'])
self.login();cid=self.client.post('/api/new_chat').json['id']
self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t'])
self.ask('我有多少异常订单',cid)
self.assertIn('先在当前列表中选择',self.ask('下一票呢',cid)['t'])
self.ask('第一个呢',cid)
self.client.post('/api/customer/logout')
self.assertTrue(self.ask('下一票呢',cid)['login_required'])
def test_navigation_and_weights_do_not_reuse_orders_after_a_quote_topic(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
self.ask('第一个呢',cid)
self.ask('美国30KG海运报价',cid)
self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t'])
def test_implicit_count_without_an_established_range_asks_for_the_missing_scope(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
text=self.ask('异常的呢',cid)['t']
self.assertIn('说明要统计的订单范围',text)
self.assertNotIn('7 票',text)
def test_list_selection_keeps_the_order_when_its_old_inbound_code_is_reassigned(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
c=db.connect()
order_data.import_records(c,[{'tenant_id':'a','order_no':'ORIGINAL20260001','inbound_code':'REUSEDINBOUND1',
'status':'PROCESSING','source_updated_at':'2030-01-01','exception_reason':'原订单资料'},
{'tenant_id':'a','order_no':'OTHER20260001','status':'PROCESSING',
'source_updated_at':'2029-01-01','exception_reason':'另一票资料'}])
c.commit();c.close()
self.ask('我有多少异常订单',cid)
with self.app.app_context():
c=db.connect()
c.execute("UPDATE order_test_data SET inbound_code='NEWINBOUND1' WHERE tenant_id='a' AND order_no='ORIGINAL20260001'")
c.execute("UPDATE order_test_data SET inbound_code='REUSEDINBOUND1' WHERE tenant_id='a' AND order_no='OTHER20260001'")
c.commit();c.close()
card=self.ask('第一个呢',cid)['card']
self.assertEqual(card['no'],'ORIGINAL20260001')
self.assertIn({'key':'inbound','value':'NEWINBOUND1'},card['fields'])
self.assertIn('原订单资料',self.ask('异常原因呢',cid)['t'])
self.assertEqual(self.ask('下一票呢',cid)['card']['no'],'OTHER20260001')
def test_adjacent_selection_follows_actual_cards_after_overlapping_requests(self):
from logistics_chat.tracking import CARD_PREFIX
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
second=self.ask('第二个呢',cid)['card']
self.ask('第一个呢',cid)
with self.app.app_context():
for _ in range(2):
db.save_message(cid,'user','下一票呢')
db.save_message(cid,'assistant',CARD_PREFIX+json.dumps(second,ensure_ascii=False))
third=self.ask('下一票呢',cid)['card']['no']
self.assertEqual(third,self.ask('第三个呢',cid)['card']['no'])
def test_ambiguous_speech_answers_the_clear_count_and_requests_the_missing_meaning(self):
self.login()
text=self.ask('当前,我的订单有多少条,一层有多少条?')['t']
+214
View File
@@ -0,0 +1,214 @@
"""Relative quantity and condition edits keep the previously confirmed quote basis."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.quote_data import import_snapshot
from test_quote_timing import timing_snapshot
from test_quotes import frames
class QuoteChangeTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'5'*64})
self.cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
c=db.connect();import_snapshot(c,timing_snapshot(),activate=True);c.commit();c.close()
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.assertFalse(any(event.get('e') for event in events),events)
return events[0]['t']
def test_weight_increments_are_added_and_absolute_changes_replace_the_total(self):
self.ask('美国1000KG义乌美东普船海派报价')
self.assertIn('1100KG = 6600元',self.ask('再加100KG,其他不变'))
self.assertIn('900KG = 5400元',self.ask('再减掉200KG'))
self.assertIn('900KG = 5400元',self.ask('那一共多少钱'))
self.assertIn('800KG = 4800元',self.ask('减到800KG'))
self.assertIn('900KG = 5400元',self.ask('增加0.1吨,总重900KG'))
def test_invalid_or_unitless_changes_stay_pending_until_a_new_total_is_confirmed(self):
self.ask('美国30KG义乌美西普船海派报价')
self.assertIn('调整后的总重需要大于0',self.ask('减去40KG'))
text=self.ask('换成美东')
self.assertIn('请提供调整后的总重',text)
self.assertNotIn('KG =',text)
self.assertIn('20KG = 120元',self.ask('总重20KG'))
self.assertIn('请提供调整后的总重',self.ask('再加100'))
self.assertNotIn('KG =',self.ask('具体多少'))
self.assertIn('50KG = 300元',self.ask('新总重50KG'))
def test_relative_quantity_without_a_confirmed_starting_value_requests_a_total(self):
self.assertIn('原来的总重',self.ask('再加100KG'))
self.assertIn('调整后的总重',self.ask('美国普船海派'))
self.assertIn('200KG = 800元',self.ask('总重200KG,义乌美西'))
def test_uniform_cartons_add_and_remove_without_losing_the_previous_cartons(self):
self.ask('美国10箱,每箱20KG,义乌美西普船海派报价')
self.assertIn('240KG = 960元',self.ask('再加2箱,每箱20KG'))
text=self.ask('减少1箱')
self.assertIn('220KG = 880元',text)
self.assertIn('共11箱',text)
self.assertIn('220KG = 880元',self.ask('那一共呢'))
def test_added_carton_dimensions_apply_to_the_added_cartons_only(self):
self.ask('美国1箱,每箱20KG,30x30x30cm,义乌美西普船海派报价')
text=self.ask('再加1箱,每箱10KG,60x50x40cm')
self.assertIn('40KG = 160元',text)
self.assertNotIn('50KG =',text)
def test_each_carton_minimum_remains_in_effect_after_addition(self):
self.ask('美国2箱,每箱5KG,义乌美西普船海派报价')
self.assertIn('36KG = 144元',self.ask('再加1箱,每箱5KG'))
def test_declared_new_totals_are_validated_after_a_carton_change(self):
self.ask('美国10箱每箱20KG,义乌美西普船海派报价')
self.assertIn('240KG = 960元',self.ask('再加2箱每箱20KG,总重240KG'))
self.assertIn('增减箱数推算的总重',self.ask('再加1箱每箱20KG,总重300KG'))
self.assertNotIn('KG =',self.ask('换成华南'))
self.assertIn('300KG',self.ask('总重300KG,共13箱'))
def test_heterogeneous_carton_removal_requires_the_updated_breakdown(self):
self.ask('美国1箱10KG,1箱30KG,义乌美西普船海派报价')
self.assertIn('不同箱规',self.ask('减少1箱'))
self.assertNotIn('KG =',self.ask('具体多少钱'))
self.assertIn('30KG = 120元',self.ask('现在1箱30KG'))
def test_rejected_services_remain_excluded_until_explicitly_selected_again(self):
self.ask('美国1000KG义乌美东海运最便宜报价')
text=self.ask('不走普船了,改成美森正班')
self.assertIn('1000KG = 10000元',text)
self.assertNotIn('普船海派',text)
text=self.ask('不要美森,走普船')
self.assertIn('1000KG = 6000元',text)
self.assertNotIn('美森',text)
self.ask('不走普船')
text=self.ask('改成华南')
self.assertNotIn('普船海派',text)
self.assertNotIn('美森',text)
self.assertIn('合德海派',text)
self.assertIn('普船海派',self.ask('那还是普船吧'))
self.assertIn('美森正班海派',self.ask('全部渠道报价'))
def test_tax_corrections_and_pending_conflicts_are_not_reversed(self):
self.ask('美国1000KG义乌美东海派报价')
self.assertIn('1000KG = 6000元',self.ask('不要不包税,我要包税'))
self.assertIn('没有匹配',self.ask('不是包税,是不包税'))
self.assertIn('1000KG = 6000元',self.ask('那还是包税吧'))
self.assertIn('1000KG = 6000元',self.ask('不是包税吗?'))
self.assertIn('明确选择包税',self.ask('不要包税,也不要不包税'))
self.assertNotIn('KG =',self.ask('换成华南'))
self.assertIn('1000KG = 6000元',self.ask('要包税'))
def test_new_batch_with_rejections_resets_old_shipping_conditions(self):
self.ask('美国1000KG义乌美东普船海派报价')
text=self.ask('新的一批美国30KG海派报价,不走普船')
self.assertIn('美国 30KG',text)
self.assertIn('华南',text)
self.assertIn('美西',text)
self.assertNotIn('普船海派',text)
self.assertNotIn('1000KG',text)
def test_calendar_questions_preserve_route_and_request_the_missing_dates(self):
self.ask('美国1000KG义乌美东普船海派报价')
for question in ('什么时候能到','大概哪天能收到'):
text=self.ask(question)
self.assertIn('25-30天左右提取',text)
self.assertIn('预计开船日期',text)
text=self.ask('快一点呢')
self.assertIn('美森正班海派',text)
self.assertNotIn('普船海派',text)
text=self.ask('那比慢的贵多少')
self.assertIn('运费差额为4000元',text)
def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self):
self.ask('美国1000KG海运最快和最慢报价')
self.assertIn('1000KG',self.ask('这个多少钱'))
self.assertIn('请指定方案名称',self.ask('第二个方案呢'))
self.assertIn('1000KG = 4000元',self.ask('便宜的那个'))
self.assertIn('1000KG = 4000元',self.ask('一共呢'))
def test_tied_speed_costs_produce_a_range_without_mixing_tax_or_units(self):
from logistics_chat.quote_comparison import speed_difference_lines
from logistics_chat.quote_data import load_book
from logistics_chat.quote_intent import parse_intent
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
rates=[{**base,'service':name,'price':price,'eta':eta} for name,price,eta in (
('快线A','8','12-15天'),('快线B','10','12-15天'),
('慢线A','5','30-35天'),('慢线B','6','30-35天'))]
rates.append({**base,'service':'自税线','tax':'excluded','price':'999','eta':'20天'})
rates.append({**base,'service':'按方线','unit':'cbm','price':'999','eta':'20天'})
with self.app.app_context():
book=load_book()
intent=parse_intent('美国1000KG义乌美东海派报价',set())
lines=speed_difference_lines(intent,book,rates,None)
self.assertTrue(any(' · 包税 · ' in line and '2000至5000元' in line for line in lines),lines)
self.assertTrue(any('不包税' in line and '差额为0元' in line for line in lines),lines)
self.assertEqual(len(lines),2)
self.assertEqual(speed_difference_lines(intent,book,[{**rates[0],'eta':'未提供时效'}],None),[])
def test_polite_relative_edits_and_unrecognized_adjustments_do_not_replace_the_total(self):
self.ask('美国1000KG义乌美东普船海派报价')
self.assertIn('1100KG = 6600元',self.ask('那我再加一百公斤'))
self.assertIn('900KG = 5400元',self.ask('请帮我减少200KG'))
self.assertIn('调整后的总重',self.ask('这批货后面增加100KG'))
self.assertNotIn('KG =',self.ask('具体多少钱'))
def test_difference_uses_the_displayed_two_decimal_costs(self):
from logistics_chat.quote_comparison import speed_difference_lines
from logistics_chat.quote_data import load_book
from logistics_chat.quote_intent import parse_intent
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
rates=[{**base,'service':'快线','price':'6.4','eta':'12天'},
{**base,'service':'慢线','price':'4.3','eta':'30天'}]
with self.app.app_context():
intent=parse_intent('美国12.555KG义乌美东海派报价',set())
text='\n'.join(speed_difference_lines(intent,load_book(),rates,None))
self.assertIn('差额为26.36元',text)
def test_difference_shows_both_speeds_for_each_origin_used_in_the_calculation(self):
with self.app.app_context():
c=db.connect()
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.assertEqual(text.count('运费差额为4000元'),2)
def test_volume_increments_keep_the_confirmed_total_and_units(self):
self.ask('美国2CBM义乌美东普船海派报价')
text=self.ask('再加1方')
self.assertIn('总体积为3CBM',text)
self.assertIn('3000元',text)
text=self.ask('总体积减少0.5CBM')
self.assertIn('总体积为2.5CBM',text)
self.assertIn('2500元',text)
def test_carton_changes_with_an_aggregate_volume_wait_for_the_updated_volume(self):
self.ask('美国2箱每箱20KG,1CBM,义乌美东普船海派报价')
self.assertIn('调整后的总体积',self.ask('再加1箱每箱20KG'))
text=self.ask('总重60KG')
self.assertIn('调整后的总体积',text)
self.assertNotIn('KG =',text)
text=self.ask('总体积1.5CBM')
self.assertIn('1500元',text)
def test_unrecognized_volume_increment_is_clarified_without_using_it_as_a_new_total(self):
self.ask('美国2CBM义乌美东普船海派报价')
text=self.ask('这批货后面增加1方')
self.assertIn('调整后的总体积',text)
self.assertNotIn('元/KG',text)
self.assertIn('3000元',self.ask('总体积3CBM'))