优化连续追问的报价条件与订单指代
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
|
||||
|
||||
@@ -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':'订单信息暂时无法读取,请稍后重试。'}
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user