优化连续追问的报价条件与订单指代

This commit is contained in:
2026-11-06 12:32:41 +08:00
parent 33e166b104
commit 0919d0bd7a
16 changed files with 487 additions and 62 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; `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_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; `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` |
| 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
@@ -78,6 +78,8 @@ tests/ # 按业务模块组织的回归
## 模块边界与依赖方向
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
+8
View File
@@ -3,7 +3,15 @@
import re
def is_acknowledgement(message):
text=re.sub(r'[\s,,。.!!??]','',message).lower()
return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|'
r'ok(?:ay)?|thanks?|thankyou|gotit|understood)+',text))
def answer_capability(message):
if is_acknowledgement(message):
return {'t':'好的,有需要随时告诉我。' if re.search(r'[\u4e00-\u9fff]',message) else 'You are welcome. Let me know if you need anything else.'}
if re.search(r'照片|图片',message) and re.search(r'报价|价格|判断|识别|尺寸',message):
return {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
if re.search(r'(?:手机号|手机号码|电话号码).*(?:查|订单|运单)|(?:查|查询).*手机号',message):
+24 -17
View File
@@ -340,6 +340,11 @@ def answer_query(message, conv_id):
if overview is not None:return overview
explanation=general_explanation(message)
if explanation:return {'t':explanation}
from .order_followups import answer_followup
followup=answer_followup(message,tenants,conv_id)
if followup is not None:
if '_order_reference' not in followup:return followup
references=[followup['_order_reference']]
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)
@@ -354,27 +359,29 @@ def answer_query(message, conv_id):
reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message):
from .db import connect
from .customer_faq import is_acknowledgement
connection = connect()
try:
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC', (conv_id,))
for role, content in recent:
if role == 'user':
if is_acknowledgement(content):continue
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
finally:
connection.close()
for role, content in recent:
if role == 'user':
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
if not reference:
if mutation:
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
+100
View File
@@ -0,0 +1,100 @@
"""Resolve bounded list references and subset questions from scoped saved replies."""
import re
import sqlite3
from .db import connect
from .order_query import COUNT,NOUN,CONDITION,parse_query
ORDINALS={word:index for index,word in enumerate(
'一 二 三 四 五 六 七 八 九 十 十一 十二 十三 十四 十五 十六 十七 十八 十九 二十'.split(),1)}
RELATIVE=r'^(?:这其中|其中|这些(?:订单|运单)?(?:中|里)?|这几(?:票|条)(?:中|里)?|里面|当中)'
def is_relative_question(message):
return (bool(re.match(RELATIVE,message))
and bool(re.search(COUNT+'|'+NOUN+'|'+CONDITION+r'|轨迹|节点|阶段|到哪|在哪',message,re.I))
and not re.search(r'报价|海运|海派|海卡|空运|运费|费用|价格|KG|公斤|千克|CBM|立方',message,re.I))
def ordinal_request(message):
from .order_data import is_order_followup
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:请|帮我|看看|看一下|查询)?第(\d{1,5}|[一二三四五六七八九十]{1,3})'
r'(?:个|票|条|笔)(?:订单|运单)?(?:的)?(.*)',text)
if not match:return None
detail=match[2]
if detail not in ('','呢','详情') and not is_order_followup(detail):return None
number=int(match[1]) if match[1].isdigit() else ORDINALS.get(match[1],0)
return number
def shown_references(connection,conv_id,state):
if 'shown' in state:
return [(item['inbound_code'] or '未登记',item['order_no']) for item in state['shown']]
row=connection.execute("SELECT content FROM messages WHERE conv_id=? AND id=? AND role='assistant'",
(conv_id,state['message_id'])).fetchone()
# Older states stored only the reply ID. Accept its fixed, unambiguous row format.
lines=[line for line in (row[0] if row else '').splitlines() if line.startswith('- 入仓码:')]
result=[]
for line in lines:
match=re.match(r'^- 入仓码:([^\r\n;]+);订单号:([^\r\n;]+);(?:业务状态|异常原因):',line)
if not match or any(line.count(label)>1 for label in (';订单号:',';业务状态:',';异常原因:')):
return []
result.append(match.groups())
return result
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)
relative=is_relative_question(text)
reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?'
r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text))
query_text=re.sub(RELATIVE,'',text)
query_text=re.sub(r'^(?:那么|那)','',query_text)
intent=parse_query(query_text+'订单有多少') if not (re.search(NOUN,query_text,re.I) or re.search(COUNT,query_text,re.I)) else parse_query(query_text)
if relative and intent is None and re.search(COUNT,query_text,re.I):intent=parse_query(query_text+'订单')
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 not tenants:return order_login_response()
try:
previous=load_state(conv_id)
if ordinal is not None:
if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'}
connection=connect()
try:
shown=shown_references(connection,conv_id,previous)
finally:connection.close()
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
if reason:
if not previous or previous.get('selected'):return None
intent={**previous['query'],'exception':True,'unsupported':False}
elif relative:
if not previous:return {'t':'请先查询要筛选的订单范围,再继续问“其中”的数量。'}
if previous.get('selected'):
return {'t':'您想统计刚才列表中的订单,还是查询当前这一票?请说明订单范围或提供入仓码。'}
if intent is None or intent['unsupported'] or 'summaries' in intent:
return {'t':'请明确要在当前订单范围内筛选的一个条件,例如“其中异常的有多少票”。'}
for key in ('status','stage'):
if previous['query'][key] and intent.get(key) and previous['query'][key]!=intent[key]:
return {'t':'当前范围与这次条件对应不同状态或节点,请确认要切换筛选条件,还是继续查看原范围。'}
intent={**intent,**{key:intent.get(key) or value for key,value in previous['query'].items()}}
elif intent.get('grouped'):
if not previous or previous.get('selected'):return None
intent={**intent,**previous['query']}
if intent is None or intent['unsupported']:return None
return answer_summary(intent,tenants,conv_id,reasons=reason)
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
+28 -6
View File
@@ -30,6 +30,9 @@ def valid_filters(query):
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_data import is_order_followup
if not conv_id:return None
connection=connect()
try:
@@ -40,8 +43,23 @@ def load_state(conv_id):
if not isinstance(state,dict) or not valid_filters(state.get('query')):return None
if type(state.get('page')) is not int or not 1<=state['page']<=10000:return None
if state.get('size') not in (5,20) or type(state.get('message_id')) is not int:return None
if 'shown' in state:
shown=state['shown']
if not isinstance(shown,list) or len(shown)>20:return None
if any(not isinstance(item,dict) or set(item)!={'inbound_code','order_no'}
or not isinstance(item['order_no'],str) or not item['order_no']
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'])):
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
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 ('查看全部','查看全部订单'):
return None
return state
@@ -166,10 +184,12 @@ def read_orders(tenants,query,page=1,size=5,comparisons=()):
finally:connection.close()
def row_text(row):
def row_text(row,reason_only=False):
from .order_data import STATUS_LABELS
def clean(value):return re.sub(r'[\r\n\t]+',' ',str(value or '未登记'))
text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])};业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}'
text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])}'
if not reason_only:
text+=f';业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}'
if row['exception_reason']:text+=';异常原因:'+clean(row['exception_reason'])
return text
@@ -196,7 +216,7 @@ def stage_distribution(groups):
f'- {label}:{groups[code]}票' for code,label in STAGE_LABELS.items() if groups[code]]
def answer_summary(intent,tenants,conv_id=None):
def answer_summary(intent,tenants,conv_id=None,reasons=False):
from .customer_service import order_login_response
if not tenants:return order_login_response()
if intent['unsupported']:return unsupported()
@@ -220,12 +240,13 @@ def answer_summary(intent,tenants,conv_id=None):
question=re.sub(r'\s+',' ',part['question'])[:120]
lines.append(f'“{question}”的筛选条件尚未明确,请确认具体含义,可按业务状态、异常或当前实际节点查询。')
if data['rows']:
lines.append(f'最近更新的{len(data["rows"])}票:')
lines.extend(row_text(row) for row in data['rows'])
lines.append('已记录的异常原因(最多5票)。' if reasons else f'最近更新的{len(data["rows"])}票:')
lines.extend(row_text(row,reason_only=reasons) for row in data['rows'])
lines.append('回复“查看全部”展开列表,或发送入仓码查询详情。')
state={'query':query,'page':1,'size':5}
previous=load_state(conv_id)
if previous and previous['query']==query:state={key:previous[key] for key in ('query','page','size')}
state['shown']=[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']]
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
@@ -251,7 +272,8 @@ def answer_list(message,tenants,conv_id=None):
if intent is None or intent['unsupported']:return unsupported(True)
query=filters(intent);page=1;size=20
data=read_orders(tenants,query,page,size);page=data['page'];total=data['total']
state={'query':query,'page':page,'size':size}
state={'query':query,'page':page,'size':size,
'shown':[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']]}
if not total:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True,'_order_list_state':state}
label=STATUS_LABELS[query['status']] if query['status'] else '全部'
first=f'先列出{size}票;' if page==1 and total>size else ''
+2 -2
View File
@@ -29,6 +29,7 @@ STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('
('processing','PROCESSING'))
NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
CONDITION = r'异常|\b(?:exception|abnormal)\b|'+'|'.join(pattern for _,pattern in NODE_PHRASES)+'|'+'|'.join(re.escape(phrase) for phrase,_ in STATUSES)
def parse_query(message,listing=False):
@@ -36,8 +37,7 @@ def parse_query(message,listing=False):
message=re.sub(r'^(?:或者|那么|那)[,,\s]*','',message)
message=re.sub(r'^(?:当前|目前|现在)[,,\s]+','',message)
has_noun=bool(re.search(NOUN,message,re.I))
condition=r'异常|\b(?:exception|abnormal)\b|'+'|'.join(pattern for _,pattern in NODE_PHRASES)+'|'+'|'.join(re.escape(phrase) for phrase,_ in STATUSES)
if not has_noun and (listing or not re.search(COUNT,message,re.I) or not re.search(condition,message,re.I)):
if not has_noun and (listing or not re.search(COUNT,message,re.I) or not re.search(CONDITION,message,re.I)):
return None
if not listing and not (re.search(COUNT,message,re.I) or re.search(r'分布|(?:都|分别).*到哪',message)):return None
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):return None
+7 -1
View File
@@ -94,7 +94,13 @@ def answer_business(intent,book):
return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。'
if mode=='fees':
lines=[]
if re.search(r'含税|含派送|全包|包不包',text):
if re.search(r'含税|包税|税费',text):
from .quote_engine import variants,match_rates
taxes={rate['tax'] for destination in variants(intent,book) for rate in match_rates(intent,book,destination)}
if taxes=={'included'}:lines.append('当前匹配方案标注包税,按该方案的税费口径核算。')
elif taxes=={'excluded'}:lines.append('当前匹配方案标注不包税,税费需单独核对。')
else:lines.append('当前存在不同税费口径,请指定包税或不包税方案继续核对。')
if re.search(r'含派送|全包|包不包',text):
lines.append('报价行标注“包税”或“不包税”的方案按各自税费方式计算;海派的派送还需核对目的仓/邮编,住宅、商业地址及偏远等附加费不能直接当作已全部包含。')
metal=material_fee(book)
if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text):
+19 -8
View File
@@ -2,7 +2,8 @@
from copy import deepcopy
from decimal import Decimal
import re
from .quote_intent import kg,MISSING_WEIGHT_UNIT
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
@@ -10,10 +11,20 @@ QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原
def merge_intent(current,previous):
result=deepcopy(current)
if (current['mode']=='quote' and previous['mode'] in ('cheapest','fastest','slowest','speed_compare')
and not re.search(r'全部|所有|各(?:个)?渠道',current['text'])):
result['mode']=previous['mode']
excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update'}
excluded={'mode','selection','clear_selection','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)
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'):
result['selection']=previous.get('selection')
if current.get('clear_selection'):
excluded.update(('services','kinds','transport','air_only','air_requested'))
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
result['metal_chargeable_weight']=None
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
@@ -30,12 +41,12 @@ def merge_intent(current,previous):
if current.get('allocations'):excluded.update(('packages','boxes','dims','cbm'))
if current['transport'] or current['kinds'] or current['services']:excluded.update(('air_only','air_requested'))
if current['air_only'] or current['tax']=='excluded':excluded.add('kinds')
if current['weight'] is not None:
if weight_change:
if not current.get('packages'):excluded.add('packages')
if not current.get('allocations'):excluded.add('allocations')
if current['dims']:excluded.add('cbm')
if current['cbm'] is not None:excluded.add('dims')
if any(current.get(k) is not None for k in ('weight','dims','cbm','boxes','packages','allocations','transport','kinds','services','tax')) and current.get('metal_chargeable_weight') is None:
if (weight_change or any(current.get(k) is not None for k in ('dims','cbm','boxes','packages','allocations','transport','kinds','services','tax'))) and current.get('metal_chargeable_weight') is None:
excluded.add('metal_chargeable_weight')
if current.get('material')=='ordinary':excluded.update(('metal_scope','metal_chargeable_weight'))
if current.get('metal_scope')=='all' and current.get('metal_chargeable_weight') is None:
@@ -49,7 +60,7 @@ def merge_intent(current,previous):
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
elif current['cbm'] is not None:
for piece in pieces:piece.update(dims=None,dim_unit='cm')
if previous.get('quantity_uncertain') and current['weight'] is None and not current.get('preserve_total'):
if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'):
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
+10 -11
View File
@@ -150,6 +150,7 @@ def render_rates(intent, book, destinations, remote_fees):
from .quote_language import translate_quote
lines = []
rate_selection=intent.get('selection') or intent.get('mode')
if intent.get('mode')=='time':
lines.append('美国线路参考时效:')
elif intent['weight']:
@@ -172,7 +173,7 @@ def render_rates(intent, book, destinations, remote_fees):
if part and intent.get('metal_chargeable_weight') is not None:
billing.update(metal_chargeable_weight=None,metal_scope='partial')
rates = match_rates(billing, book, destination)
if intent.get('mode')=='cheapest' and rates:
if rate_selection=='cheapest' and rates:
eligible=[r for r in rates if quantity(billing,r) is not None]
best={}
for rate in eligible:
@@ -187,11 +188,11 @@ def render_rates(intent, book, destinations, remote_fees):
if destination['label'] != '美国':
lines.append(destination['label']+':')
selections=[('',rates)]
if intent.get('mode') in ('fastest','slowest','speed_compare'):
if rate_selection in ('fastest','slowest','speed_compare'):
selections=[]
if intent['mode'] in ('fastest','speed_compare'):
if rate_selection in ('fastest','speed_compare'):
selections.append(('最快参考方案',select_timed_rates(rates)))
if intent['mode'] in ('slowest','speed_compare'):
if rate_selection in ('slowest','speed_compare'):
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True)))
lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
grouped = {}
@@ -206,10 +207,11 @@ def render_rates(intent, book, destinations, remote_fees):
if selection and selection!=last_selection:
lines.append(selection)
last_selection=selection
eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip()
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta
if intent.get('mode')=='time':
eta=eta_source or '当前资料未列明时效'
if rows[0]['kind']=='parcel' and '提取' not in eta:eta+='提取'
lines.append(f'- {service} · {origin}:{eta}。提取不等于送达或签收。')
lines.append(f'- {service} · {origin},{eta or "当前资料未列明时效"}。')
continue
parts = []
for rate in rows:
@@ -228,9 +230,6 @@ def render_rates(intent, book, destinations, remote_fees):
elif intent.get('cbm') and rate['unit']=='cbm':
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
parts.append(rate_text)
eta = re.sub(r'\s*无时效赔偿', '', eta_source).strip()
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
+ (f'({eta})' if eta else ''))
if fees:
@@ -241,7 +240,7 @@ def render_rates(intent, book, destinations, remote_fees):
if not any(intent.get(key) for key in ('warehouse','postal')):
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
if intent.get('mode')=='time':
pass
lines.append('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。')
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
lines.append('货物多少公斤、几箱?')
elif not intent['boxes'] or not intent['dims']:
+32 -10
View File
@@ -9,10 +9,16 @@ US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))',re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|收费|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*(kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅)(?![A-Za-z])',re.I)
WEIGHT_UNITS = r'kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅'
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*('+WEIGHT_UNITS+r')(?![A-Za-z])',re.I)
MISSING_WEIGHT_UNIT = '请补充修改后重量的单位,例如KG或吨,我再按新重量核算。'
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
def resumes_quote(message):
return bool(re.search(r'(?:回到|回头|继续)(?:刚才|之前|前面|上面)(?:的)?(?:美国)?(?:海运)?(?:报价|价格|运费)',message))
def chinese_number(text):
digits={c:i for i,c in enumerate('零一二三四五六七八九')};digits.update({'两':2,'〇':0})
if all(c in digits for c in text):return ''.join(str(digits[c]) for c in text)
@@ -45,6 +51,16 @@ def kg(value,unit):
return value
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))
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
if fastest and slowest:return 'speed_compare'
if fastest:return 'fastest'
if slowest:return 'slowest'
return None
def request_mode(text):
# Business topics outrank generic verbs such as “怎么算”.
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
@@ -58,21 +74,15 @@ 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'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text):return 'total'
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'
if slowest:return 'slowest'
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'
return 'quote'
if re.search(r'时效|多久|几天|多快(?:能|会)?到',text) and not WEIGHT.search(text):return 'time'
return request_selection(text) or 'quote'
def parse_intent(message,warehouse_codes):
@@ -90,6 +100,10 @@ def parse_intent(message,warehouse_codes):
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
and not t.startswith(('USD','RMB','CNY'))]
errors=materials['errors'][:]
pending_weight=re.search(r'(?:总重量|总重|重量|毛重|实重)\s*(?:改成|改为|改到|换成|调整为|是|为)?\s*'
r'(-?\d+(?:\.\d+)?)(?![\d.])(?!\s*(?:'+WEIGHT_UNITS+r')(?![A-Za-z]))',text,re.I)
if pending_weight:errors.append(MISSING_WEIGHT_UNIT)
weight_unit=re.fullmatch(r'(?:单位(?:是|用|为)?|按)?\s*('+WEIGHT_UNITS+r')(?:计算|算|计)?[。.!!??\s]*',text,re.I)
from .quote_allocations import allocations
assigned=allocations(quantity_text,warehouses,WEIGHT,kg)
weights=[m for m in WEIGHT.finditer(quantity_text) if not quantity_text[:m.start()].rstrip().endswith(('x','X','×','*'))]
@@ -158,6 +172,7 @@ def parse_intent(message,warehouse_codes):
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
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)
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'],
@@ -165,7 +180,11 @@ def parse_intent(message,warehouse_codes):
'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),
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) 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,
'clear_selection':bool(re.search(r'(?:全部|所有|各个?)(?:的)?(?:渠道|线路|方案)|(?:全部|所有)(?:报价|价格|运价)',text)),
'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
'material':materials['material'],'metal_scope':materials['metal_scope'],
'metal_chargeable_weight':materials['metal_chargeable_weight'],
'metal_weight_update':materials['metal_weight_update'],
@@ -174,6 +193,8 @@ 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
text=message.strip().strip(',,。.!!?? ')
@@ -182,6 +203,7 @@ def is_followup(message,intent):
if intent.get('warehouse') or intent.get('unknown_warehouses'):
return not (intent['country'] and intent['weight'])
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
if re.fullmatch(r'具体(?:多少|多少钱)(?:呢)?',text):return True
if re.fullmatch(r'(?:请|请问|帮我|给我|请给我|麻烦)?(?:再|继续)?(?:提供|显示|列出|看一下|看看|查询)?'
r'(?:具体|详细|全部渠道|所有渠道|各渠道)?(?:报价|价格|运费|费用)(?:呢|是多少|多少|怎么样|如何|多少钱)?',text):return True
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
+1
View File
@@ -15,6 +15,7 @@ def translate_quote(text):
('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。','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?'),
('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。','These times run from sailing to pickup. Delivery timing depends on last-mile progress.'),
('当前资料没有美国空运费率,下面列出海运方案。','The current rate sheet has no US air rates. Sea options are listed below.'),
('以下按各目的仓分别比较;实际分票请提供每个仓的重量。','Each destination is compared separately; provide the weight assigned to each shipment.'),
('按相同区域、计费单位及税费方式比较的较低价方案:','Lower-priced options within the same region, billing unit and tax basis:'),
+28 -5
View File
@@ -7,21 +7,40 @@ import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
from .quote_intent import resumes_quote
from .customer_faq import is_acknowledgement
def previous_quote_intents(conv_id,codes):
def previous_quote_intents(conv_id,codes,resume=False):
"""Read the current quote back to its topic boundary, without a turn cutoff."""
earlier_intents=[]
resume_updates=[]
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:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous):break
if is_acknowledgement(previous):continue
earlier=parse_intent(previous,codes)
if resumes_quote(previous):
earlier_intents.append(earlier)
# Retain edits made after an explicit return while crossing other topics.
resume_updates=earlier_intents[:]
resume=True
continue
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous))
quote_root=bool((earlier['country'] or earlier['warehouse'] or earlier.get('unknown_warehouses'))
and QUOTE_WORDS.search(previous))
if quote_root and not re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):
boundary=False
boundary=boundary or bool(re.search(r'订单|运单|入仓码',previous) and not re.search(r'报价|海运|海派|海卡|空运',previous))
candidate=(QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal')))
if boundary or not candidate:
if not resume:break
earlier_intents=resume_updates[:]
continue
if earlier['country']=='other':break
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
earlier_intents.append(earlier)
if not is_followup(previous,earlier):break
finally:
@@ -35,7 +54,7 @@ def intent_with_context(message, conv_id, book):
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
earlier_intents=previous_quote_intents(conv_id,codes) if is_followup(message,intent) else []
earlier_intents=previous_quote_intents(conv_id,codes,resume=resumes_quote(message)) if is_followup(message,intent) else []
if intent.get('metal_weight_update') and not earlier_intents:
intent['metal_chargeable_weight']=None
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
@@ -64,6 +83,10 @@ def answer_quote(message, conv_id):
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if resumes_quote(message):
return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'}
if intent.get('weight_unit'):
return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'}
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None
+108
View File
@@ -1,6 +1,7 @@
"""Statistics use explicit exception reasons and actual, order-level progress."""
import json
import re
from pathlib import Path
import sqlite3
import tempfile
@@ -48,6 +49,7 @@ class OrderStatisticsTests(unittest.TestCase):
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
self.assertNotIn('_order_list_state',str(events));self.assertNotIn('_customer_data',str(events))
self.assertNotIn('_order_reference',str(events))
self.assertNotIn('SECRET',str(events));self.assertNotIn('不能泄露',str(events))
return events[0]
@@ -155,6 +157,112 @@ class OrderStatisticsTests(unittest.TestCase):
self.assertIn('7 票明确标记异常',text)
self.assertEqual(text.count('- 入仓码:'),5)
def test_subset_followups_keep_previous_stage_and_show_recorded_reasons(self):
self.assertTrue(self.ask('异常的呢')['login_required'])
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('已清关的订单有多少',cid)
text=self.ask('其中异常的呢',cid)['t']
self.assertIn('1 票当前节点为“已清关、可提柜”',text)
self.assertIn('等待客户资料',text)
self.assertNotIn('待核实资料',text)
self.assertIn('1 票当前节点',self.ask('这些一共多少票',cid)['t'])
text=self.ask('异常原因呢',cid)['t']
self.assertIn('等待客户资料',text)
self.assertNotIn('业务状态:',text)
self.assertIn('共1票',self.ask('查看全部',cid)['t'])
def test_ordinal_uses_the_displayed_reply_even_when_new_orders_arrive(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
text=self.ask('我有多少异常订单',cid)['t']
shown=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)
with self.app.app_context():
c=db.connect()
order_data.import_records(c,[{'tenant_id':'a','order_no':'NEWEST20260001','inbound_code':'NEWESTIN20260001',
'status':'PROCESSING','source_updated_at':'2030-01-01','exception_reason':'新记录'}])
c.commit();c.close()
card=self.ask('第一票的轨迹',cid)['card']
self.assertEqual(card['no'],shown[0][1])
card=self.ask('第二个呢',cid)['card']
self.assertEqual(card['no'],shown[1][1])
self.assertNotIn('NEWEST',str(card))
def test_selected_order_survives_acknowledgements_and_long_detail_followups(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
first=self.ask('第一个呢',cid)['card']['no']
for _ in range(8):
self.ask('谢谢',cid)
text=self.ask('异常原因呢',cid)['t']
self.assertNotIn('请提供',text)
self.assertEqual(self.ask('重量呢',cid)['card']['no'],first)
def test_empty_or_out_of_range_ordinals_never_choose_an_unshown_order(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
self.assertIn('展示了5票',self.ask('第6票的轨迹',cid)['t'])
self.ask('已还柜的订单有多少',cid)
self.assertIn('当前列表没有',self.ask('第一个呢',cid)['t'])
self.assertIn('0 票当前节点为“已还柜”',self.ask('到哪了',cid)['t'])
def test_ordinals_and_subsets_require_the_current_owned_topic_and_login(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
self.ask('美国30KG海运报价',cid)
text=self.ask('第一票的轨迹',cid)['t']
self.assertIn('请先查看',text)
self.assertNotIn('INSTAT',text)
self.ask('我有多少异常订单',cid)
self.client.post('/api/customer/logout')
self.assertTrue(self.ask('第一个呢',cid)['login_required'])
self.assertTrue(self.ask('其中异常的呢',cid)['login_required'])
def test_conflicting_subset_conditions_request_a_scope_change(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('已清关的订单有多少',cid)
self.assertIn('确认要切换筛选条件',self.ask('其中已到港的呢',cid)['t'])
self.assertIn('10 票当前节点',self.ask('那已到港的呢',cid)['t'])
def test_subset_clarification_keeps_the_original_scope_for_the_next_answer(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('已清关的订单有多少',cid)
self.assertIn('请明确',self.ask('其中今天的异常呢',cid)['t'])
self.assertIn('1 票当前节点',self.ask('其中异常的呢',cid)['t'])
def test_a_shipping_quote_starting_with_these_is_a_new_quote_topic(self):
from logistics_chat.quote_data import import_snapshot
from test_quotes import fixture_snapshot
with self.app.app_context():
c=db.connect();import_snapshot(c,fixture_snapshot(),activate=True);c.commit();c.close()
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少个订单',cid)
text=self.ask('这些货物美国30KG海运报价',cid)['t']
self.assertIn('美国 30KG',text)
self.assertNotIn('订单范围',text)
self.assertIn('请先查看',self.ask('第一个订单呢',cid)['t'])
def test_legacy_list_state_can_resolve_ordinals_without_saved_identifiers(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
text=self.ask('我有多少异常订单',cid)['t']
first=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)[0][1]
with self.app.app_context():
c=db.connect()
state=json.loads(c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0])
del state['shown']
c.execute('UPDATE chat_history SET order_list_state=? WHERE id=?',(json.dumps(state),cid))
c.commit();c.close()
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],first)
def test_ordinal_uses_saved_identifiers_when_a_field_contains_display_separators(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':'STRUCTURED20260001',
'inbound_code':'VALUE;订单号:OTHER20260001','status':'PROCESSING',
'source_updated_at':'2030-01-01','exception_reason':'待核实'}])
c.commit();c.close()
self.ask('我有多少异常订单',cid)
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],'STRUCTURED20260001')
def test_ambiguous_speech_answers_the_clear_count_and_requests_the_missing_meaning(self):
self.login()
text=self.ask('当前,我的订单有多少条,一层有多少条?')['t']
+1 -1
View File
@@ -114,7 +114,7 @@ class QuoteBusinessTests(unittest.TestCase):
text=self.ask('海运和空运哪个好,美国30KG')
self.assertIn('没有美国空运费率',text)
text=self.ask('美国美西海派多久')
self.assertIn('提取不等于送达',text);self.assertNotIn('元/KG',text)
self.assertIn('按开船后到提取计算',text);self.assertNotIn('元/KG',text)
text=self.ask('Please quote 30kg to US by sea')
self.assertIn('CNY/kg',text)
self.assertFalse(__import__('re').search(r'[\u4e00-\u9fff]',text),text)
+116
View File
@@ -119,3 +119,119 @@ class QuoteTimingTests(unittest.TestCase):
self.assertIn('最快参考方案暂时无法确定',text)
self.assertIn('最慢参考方案暂时无法确定',text)
self.assertNotIn('1000KG =',text)
def test_selected_route_survives_total_calculation_time_and_tax_questions(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国1000KG义乌美东海运,便宜的报价',cid)
for question in ('那一共多少钱','怎么算出来的'):
text=self.ask(question,cid)
self.assertIn('普船海派',text)
self.assertIn('1000KG = 6000元',text)
self.assertNotIn('美森正班海派',text)
text=self.ask('多快能到',cid)
self.assertIn('普船海派',text)
self.assertIn('船开后25-30天左右提取',text)
self.assertNotIn('美森正班海派',text)
self.assertIn('标注包税',self.ask('含税吗',cid))
text=self.ask('具体多少',cid)
self.assertIn('1000KG = 6000元',text)
self.assertNotIn('美森正班海派',text)
text=self.ask('全部渠道报价',cid)
self.assertIn('美森正班海派',text)
self.assertIn('普船海派',text)
def test_polite_acknowledgements_keep_quote_conditions_and_selection(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国30KG义乌美西最快海派报价',cid)
for question in ('谢谢','好的,谢谢您','OK'):
self.ask(question,cid)
text=self.ask('改成50KG呢',cid)
self.assertIn('美森正班海派 · 义乌',text)
self.assertIn('50KG = 400元',text)
self.assertNotIn('普船海派',text)
text=self.ask('所有箱都是普通货',cid)
self.assertIn('最快参考方案',text)
self.assertNotIn('普船海派',text)
def test_missing_weight_unit_stays_pending_until_an_explicit_unit_is_supplied(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国1000KG义乌美西海运最快报价',cid)
for question in ('重量改成800,其他不变','换成华南','谢谢','美东呢'):
text=self.ask(question,cid)
if question!='谢谢':
self.assertIn('单位',text)
self.assertNotIn('1000KG =',text)
text=self.ask('单位是KG',cid)
self.assertIn('美森正班海派 · 华南',text)
self.assertIn('800KG = 8000元',text)
self.assertNotIn('1000KG',text)
self.assertIn('800KG = 8000元',self.ask('具体多少',cid))
def test_quote_resume_is_explicit_and_survives_later_field_edits(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国30KG义乌美西最快报价',cid)
self.ask('我有多少个订单',cid)
text=self.ask('50KG呢',cid)
self.assertNotIn('美森正班海派',text)
text=self.ask('回到刚才的报价',cid)
self.assertIn('30KG = 240元',text)
self.assertNotIn('50KG',text)
text=self.ask('换成华南',cid)
self.assertIn('美森正班海派 · 华南',text)
self.assertIn('30KG = 240元',text)
def test_resume_does_not_recover_us_conditions_across_a_new_country_quote(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国30KG义乌海运报价',cid)
with self.app.app_context():
db.save_message(cid,'user','新的一票100KG发德国海运报价')
db.save_message(cid,'assistant','请核对对应目的地的报价。')
self.ask('我有多少个订单',cid)
text=self.ask('回到刚才的报价',cid)
self.assertIn('目的国家',text)
self.assertNotIn('美国 30KG',text)
self.assertNotIn('元/KG',text)
def test_new_quote_with_a_topic_switch_becomes_the_next_followup_root(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国30KG义乌美西最快报价',cid)
self.ask('换个话题,美国50KG华南美东海运报价',cid)
text=self.ask('最慢的呢',cid)
self.assertIn('普船海派 · 华南',text)
self.assertIn('50KG = 300元',text)
self.assertNotIn('30KG',text)
self.ask('另外问美国海运报价',cid)
text=self.ask('20KG呢',cid)
self.assertIn('美国 20KG',text)
self.assertIn('义乌',text)
self.assertNotIn('50KG',text)
def test_explicit_selection_and_business_question_are_retained_independently(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国1000KG义乌美东海运报价',cid)
self.assertIn('标注包税',self.ask('最快的含税吗',cid))
text=self.ask('那一共多少钱',cid)
self.assertIn('1000KG = 10000元',text)
self.assertNotIn('普船海派',text)
text=self.ask('最快多久能提取',cid)
self.assertIn('15天左右提取',text)
self.assertNotIn('普船海派',text)
def test_missing_weight_units_before_a_destination_do_not_reuse_the_old_weight(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国1000KG义乌美西最快海运报价',cid)
self.assertIn('单位',self.ask('重量改成800美东,其他不变',cid))
text=self.ask('KG',cid)
self.assertIn('800KG = 8000元',text)
self.assertNotIn('1000KG',text)
def test_all_channels_clears_the_named_service_and_keeps_origin_destination_weight(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国1000KG义乌美东普船海派最便宜报价',cid)
text=self.ask('全部渠道报价',cid)
self.assertIn('美森正班海派',text)
self.assertIn('合德海派',text)
self.assertIn('普船海派',text)
self.assertIn('1000KG = 10000元',text)
self.assertNotIn('华南',text)
self.assertNotIn('美西 ',text)