Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa6471929 | ||
|
|
bcfccc7013 | ||
|
|
0f01a4a833 | ||
|
|
33e166b104 | ||
|
|
7b3b3d30ba |
@@ -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 and quantity adjustments; `quote_comparison.py`: comparable fast/slow costs; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals, adjacent rows and subset questions; `order_tracking.py`: `current_stage` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||
|
||||
Vendored
+3
-3
@@ -40,10 +40,10 @@ pipeline {
|
||||
steps {
|
||||
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}", "DEBIAN_MIRROR=${params.DEBIAN_MIRROR ?: 'https://deb.debian.org'}"]) {
|
||||
sh '''set -eu
|
||||
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" -t "$CHAT_IMAGE-test" .
|
||||
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||
sh deploy/build-image.sh --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" -t "$CHAT_IMAGE-test" .
|
||||
sh deploy/build-image.sh --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CHAT_IMAGE" .
|
||||
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||
sh deploy/build-image.sh --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CERTBOT_IMAGE" .
|
||||
'''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。
|
||||
available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo)
|
||||
case "$available_kib" in
|
||||
''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;;
|
||||
esac
|
||||
if [ "$available_kib" -lt 4194304 ]; then
|
||||
echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。
|
||||
# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。
|
||||
exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \
|
||||
docker build --memory=3g --memory-swap=3g \
|
||||
--cpu-period=100000 --cpu-quota=200000 --force-rm "$@"
|
||||
@@ -78,6 +78,10 @@ tests/ # 按业务模块组织的回归
|
||||
|
||||
## 模块边界与依赖方向
|
||||
|
||||
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
|
||||
|
||||
报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。
|
||||
|
||||
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
|
||||
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
|
||||
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
`java-builder` 标签代理须有 Docker、Bash、Python 3、Node.js 18+、OpenSSL、SSH,并能下载所选 Python 基础镜像和 PyPI 依赖。标签不表示固定物理节点。
|
||||
|
||||
三个镜像目标均通过 `deploy/build-image.sh` 构建。当前 Docker 26 节点显式使用经典构建器,每个构建容器限制为 3GiB 内存及内存/交换总额、2 CPU;宿主机可用内存不足 4GiB 或无法读取时拒绝启动。单次构建由 GNU timeout 限制为 15 分钟,TERM 后 30 秒强制终止,流水线总上限仍为 30 分钟。正常构建失败也移除中间容器;服务器重启留下的退出容器只占磁盘。构建节点保持单执行器,须提供 GNU timeout 和 awk,切换构建后端时重新验证硬限制。离线回归使用 `node --test tests/frontend/build_resources.test.cjs`。
|
||||
|
||||
以下参数由私有灰度配置提供,实际地址和凭据值不写入源码:
|
||||
|
||||
| 参数 | 含义 |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -23,7 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipmen
|
||||
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|异常|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
ORDER_DETAIL = r'状态|进度|轨迹|异常|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|多重|重多少|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||
|
||||
|
||||
class OrderDataUnavailable(Exception):
|
||||
@@ -138,7 +138,7 @@ def tenant_filter(tenant_id=None, tenant_ids=None):
|
||||
return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', [])
|
||||
|
||||
|
||||
def lookup_order(reference, tenant_id=None, tenant_ids=None):
|
||||
def lookup_order(reference, tenant_id=None, tenant_ids=None, *, exact_order=False):
|
||||
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||
from .db import connect
|
||||
|
||||
@@ -149,8 +149,9 @@ def lookup_order(reference, tenant_id=None, tenant_ids=None):
|
||||
try:
|
||||
connection = connect()
|
||||
connection.row_factory = sqlite3.Row
|
||||
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0]
|
||||
values = [reference] * 3 + scope[1]
|
||||
condition='order_no=?' if exact_order else '(order_no=? OR inbound_code=? OR shipment_id=?)'
|
||||
query = 'SELECT * FROM order_test_data WHERE ' + condition + scope[0]
|
||||
values = [reference] * (1 if exact_order else 3) + scope[1]
|
||||
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||
except sqlite3.Error:
|
||||
raise OrderDataUnavailable from None
|
||||
@@ -332,6 +333,7 @@ def is_order_followup(message):
|
||||
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
exact_order = False
|
||||
tenants = customer_tenant_ids()
|
||||
from .order_explanations import general_explanation, detail_question, explain_order
|
||||
if not references:
|
||||
@@ -340,6 +342,12 @@ 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']]
|
||||
exact_order=True
|
||||
count_intent = parse_count_query(message) if not references else None
|
||||
if count_intent is not None:
|
||||
return answer_count_query(count_intent,tenants,conv_id)
|
||||
@@ -354,27 +362,30 @@ 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']
|
||||
exact_order=True
|
||||
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':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
|
||||
@@ -392,7 +403,9 @@ def answer_query(message, conv_id):
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
try:
|
||||
matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
|
||||
scope={'tenant_id':tenants[0]} if len(tenants)==1 else {'tenant_ids':tenants}
|
||||
if exact_order:scope['exact_order']=True
|
||||
matches = lookup_order(reference,**scope)
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if not matches:
|
||||
|
||||
@@ -4,9 +4,12 @@ import re
|
||||
|
||||
|
||||
def detail_question(message):
|
||||
from .quote_edits import quantity_adjustment
|
||||
if quantity_adjustment(message):
|
||||
return False
|
||||
if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message):
|
||||
return False
|
||||
return bool(re.search(r'异常原因|为什么异常|什么异常|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
|
||||
return bool(re.search(r'异常原因|为什么异常|什么异常|多重|重多少|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
|
||||
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|'
|
||||
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
|
||||
|
||||
@@ -25,6 +28,9 @@ def explain_order(message,row,card):
|
||||
if re.search(r'异常原因|为什么异常|什么异常',message):
|
||||
reason=(row.get('exception_reason') or '').strip()
|
||||
return lines[0]+('已记录的异常原因:'+reason if reason else '当前未登记异常原因,请联系人工核实。')
|
||||
if re.search(r'多重|重多少',message):
|
||||
weight=row.get('gross_weight_kg')
|
||||
return lines[0]+(f'登记的申报毛重为{weight}KG。' if weight is not None else '当前未登记申报毛重,请联系人工核对重量。')
|
||||
if re.search(r'催单|催一下|拦截',message):
|
||||
return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。'
|
||||
if re.search(r'付过款|付款状态',message):
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Resolve bounded list references and subset questions from scoped saved replies."""
|
||||
|
||||
import json
|
||||
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 list_step(message):
|
||||
from .order_data import is_order_followup
|
||||
text=re.sub(r'[\s,,。.!!??]','',message)
|
||||
match=re.fullmatch(r'(?:那|再看|看看|查一下|查询)?(下一|后一|上一|前一)'
|
||||
r'(?:票|个|条|单)(?:订单|运单)?(?:的)?(.*)',text)
|
||||
if not match:return None
|
||||
if match[2] not in ('','呢','详情') and not is_order_followup(match[2]):return None
|
||||
return 1 if match[1] in ('下一','后一') else -1
|
||||
|
||||
|
||||
def shown_references(connection,conv_id,state):
|
||||
if 'shown' in state:
|
||||
return [(item['inbound_code'] or '未登记',item['order_no']) for item in state['shown']]
|
||||
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 selected_row(content,shown):
|
||||
from .tracking import CARD_PREFIX
|
||||
try:
|
||||
card=json.loads(content[len(CARD_PREFIX):])
|
||||
if card.get('kind')!='order' or not isinstance(card.get('no'),str):return None
|
||||
matches=[index for index,(_,number) in enumerate(shown,1) if number.casefold()==card['no'].casefold()]
|
||||
if len(matches)>1:
|
||||
fields=card.get('fields')
|
||||
if not isinstance(fields,list):return None
|
||||
inbound=next((field.get('value') for field in fields if isinstance(field,dict) and field.get('key')=='inbound'),None)
|
||||
matches=[index for index in matches if shown[index-1][0]==inbound]
|
||||
return matches[0] if len(matches)==1 else None
|
||||
except (ValueError,TypeError,AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def answer_followup(message,tenants,conv_id):
|
||||
from .customer_service import order_login_response
|
||||
from .order_listing import load_state,answer_summary
|
||||
|
||||
text=message.strip(' ,,。.!!??')
|
||||
ordinal=ordinal_request(text)
|
||||
step=list_step(text)
|
||||
relative=is_relative_question(text)
|
||||
reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?'
|
||||
r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text))
|
||||
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 step is None and not relative and not reason and not short_count:return None
|
||||
if not tenants:return order_login_response()
|
||||
try:
|
||||
previous=load_state(conv_id)
|
||||
if step is not None:
|
||||
if not previous:return {'t':'请先查看订单列表并选择一票,或直接提供入仓码。'}
|
||||
selected=previous.get('selected_index')
|
||||
if selected is None:return {'t':'请先在当前列表中选择一票,例如“第一个”,再继续查看上一票或下一票。'}
|
||||
connection=connect()
|
||||
try:shown=shown_references(connection,conv_id,previous)
|
||||
finally:connection.close()
|
||||
ordinal=selected+step
|
||||
if not 1<=ordinal<=len(shown):
|
||||
command='下一页' if step>0 else '上一页'
|
||||
edge='最后一票' if step>0 else '第一票'
|
||||
return {'t':f'当前展示列表已到{edge}。可回复“{command}”翻页,或提供入仓码查询其他订单。'}
|
||||
if ordinal is not None:
|
||||
if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'}
|
||||
connection=connect()
|
||||
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)}票,请选择其中一票,或回复“查看全部”展开列表后再查询。'}
|
||||
return {'_order_reference':shown[ordinal-1][1]}
|
||||
if short_count and not relative and (not previous or previous.get('selected')):
|
||||
from .order_data import is_order_followup
|
||||
if is_order_followup(text):return None
|
||||
return {'t':'请先说明要统计的订单范围,例如“我的异常订单有多少”,或提供入仓码查询单票情况。'}
|
||||
if reason:
|
||||
if not previous or previous.get('selected'):return None
|
||||
intent={**previous['query'],'exception':True,'unsupported':False}
|
||||
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,10 @@ 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,list_step,shown_references,selected_row,is_relative_question
|
||||
from .order_data import is_order_followup
|
||||
from .tracking import CARD_PREFIX
|
||||
if not conv_id:return None
|
||||
connection=connect()
|
||||
try:
|
||||
@@ -40,8 +44,32 @@ 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
|
||||
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 '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
|
||||
state['selected_index']=None
|
||||
visible=shown_references(connection,conv_id,state)
|
||||
for role,text in connection.execute('SELECT role,content FROM messages WHERE conv_id=? AND id>? ORDER BY id',
|
||||
(conv_id,state['message_id'])):
|
||||
if role!='user':
|
||||
if role=='assistant' and text.startswith(CARD_PREFIX):
|
||||
state['selected_index']=selected_row(text,visible)
|
||||
state['selected']=state['selected_index'] is not None
|
||||
continue
|
||||
if is_acknowledgement(text):continue
|
||||
if is_relative_question(text):continue
|
||||
ordinal=ordinal_request(text)
|
||||
if ordinal is not None:
|
||||
continue
|
||||
step=list_step(text)
|
||||
if step is not None:
|
||||
continue
|
||||
if state['selected'] and is_order_followup(text):continue
|
||||
if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'):
|
||||
return None
|
||||
return state
|
||||
@@ -135,31 +163,43 @@ def answer_overview(message,tenants):
|
||||
return {'t':'\n'.join(lines),'_customer_data':True}
|
||||
|
||||
|
||||
def read_orders(tenants,query,page=1,size=5):
|
||||
def matches_filters(row,query):
|
||||
return ((not query['status'] or row['status']==query['status'])
|
||||
and (not query['stage'] or row['stage']==query['stage'])
|
||||
and (not query['exception'] or bool(row['exception_reason'])))
|
||||
|
||||
|
||||
def read_orders(tenants,query,page=1,size=5,comparisons=()):
|
||||
"""One scoped snapshot; each order occupies one actual-node bucket."""
|
||||
if not valid_filters(query):raise ValueError('Explicit filters required')
|
||||
if not valid_filters(query) or any(not valid_filters(part) for part in comparisons):
|
||||
raise ValueError('Explicit filters required')
|
||||
connection=connect()
|
||||
try:
|
||||
connection.row_factory=sqlite3.Row
|
||||
connection.execute('BEGIN')
|
||||
total=with_inbound=0;groups=Counter();selected=[];tail=deque(maxlen=size)
|
||||
for item in scoped_orders(connection,tenants,query['status']):
|
||||
if query['exception'] and not item['exception_reason']:continue
|
||||
if query['stage'] and query['stage']!=item['stage']:continue
|
||||
comparison_groups=[Counter() for _ in comparisons]
|
||||
for item in scoped_orders(connection,tenants):
|
||||
for index,comparison in enumerate(comparisons):
|
||||
if matches_filters(item,comparison):comparison_groups[index][item['stage']]+=1
|
||||
if not matches_filters(item,query):continue
|
||||
total+=1;with_inbound+=bool((item['inbound_code'] or '').strip());groups[item['stage']]+=1
|
||||
tail.append(item)
|
||||
if (page-1)*size<total<=page*size:selected.append(item)
|
||||
last=max(1,(total+size-1)//size)
|
||||
if page>last:
|
||||
page=last;selected=list(tail)[-(total-(last-1)*size):] if total else []
|
||||
return {'total':total,'with_inbound':with_inbound,'groups':groups,'rows':selected,'page':page}
|
||||
return {'total':total,'with_inbound':with_inbound,'groups':groups,'rows':selected,'page':page,
|
||||
'counts':[sum(group.values()) for group in comparison_groups],'comparison_groups':comparison_groups}
|
||||
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
|
||||
|
||||
@@ -168,34 +208,55 @@ def unsupported(listing=False):
|
||||
return {'t':f'暂不支持按这个条件{"列出订单" if listing else "统计"}。可以查询订单总数、业务状态、明确标记的异常,或按当前实际物流节点统计;请一次指定一个节点。'}
|
||||
|
||||
|
||||
def answer_summary(intent,tenants,conv_id=None):
|
||||
from .customer_service import order_login_response
|
||||
def summary_text(query,total):
|
||||
from .order_data import STATUS_LABELS
|
||||
if query['stage']:
|
||||
text=f'您当前有 {total} 票当前节点为“{STAGE_LABELS[query["stage"]]}”的订单'
|
||||
elif query['status']:
|
||||
text=f'您当前有 {total} 票业务状态为“{STATUS_LABELS[query["status"]]}”的订单'
|
||||
elif query['exception']:text=f'您当前有 {total} 票明确标记异常的订单'
|
||||
else:text=f'您当前共有 {total} 票订单'
|
||||
if query['exception'] and (query['stage'] or query['status']):text+='(已明确标记异常)'
|
||||
if query['stage'] and query['status']:text+=f'(业务状态为{STATUS_LABELS[query["status"]]})'
|
||||
return text
|
||||
|
||||
|
||||
def stage_distribution(groups):
|
||||
return ['按当前实际物流节点分布(每票只计一次):']+[
|
||||
f'- {label}:{groups[code]}票' for code,label in STAGE_LABELS.items() if groups[code]]
|
||||
|
||||
|
||||
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()
|
||||
query=filters(intent)
|
||||
requested=intent.get('summaries') or [intent]
|
||||
summaries=[part for part in requested if not part['unsupported']]
|
||||
if not summaries:return unsupported()
|
||||
query=filters(summaries[-1])
|
||||
comparisons=[filters(part) for part in summaries[:-1]]
|
||||
try:
|
||||
data=read_orders(tenants,query)
|
||||
data=read_orders(tenants,query,comparisons=comparisons)
|
||||
total=data['total']
|
||||
if query['stage']:
|
||||
text=f'您当前有 {total} 票当前节点为“{STAGE_LABELS[query["stage"]]}”的订单'
|
||||
elif query['status']:
|
||||
text=f'您当前有 {total} 票业务状态为“{STATUS_LABELS[query["status"]]}”的订单'
|
||||
elif query['exception']:text=f'您当前有 {total} 票明确标记异常的订单'
|
||||
else:text=f'您当前共有 {total} 票订单'
|
||||
if query['exception'] and (query['stage'] or query['status']):text+='(已明确标记异常)'
|
||||
if query['stage'] and query['status']:text+=f'(业务状态:{STATUS_LABELS[query["status"]]})'
|
||||
lines=[text+f',其中 {data["with_inbound"]} 票已登记入仓码。']
|
||||
if intent.get('grouped'):
|
||||
lines.append('按当前实际物流节点分布(每票只计一次):')
|
||||
lines.extend(f'- {label}:{data["groups"][code]}票' for code,label in STAGE_LABELS.items() if data['groups'][code])
|
||||
lines=[]
|
||||
for part,count,groups in zip(summaries[:-1],data['counts'],data['comparison_groups']):
|
||||
lines.append(summary_text(part,count)+'。')
|
||||
if part.get('grouped'):lines.extend(stage_distribution(groups))
|
||||
lines.append(summary_text(query,total)+f',其中 {data["with_inbound"]} 票已登记入仓码。')
|
||||
if summaries[-1].get('grouped'):
|
||||
lines.extend(stage_distribution(data['groups']))
|
||||
for part in requested:
|
||||
if part['unsupported']:
|
||||
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':'订单信息暂时无法读取,请稍后重试。'}
|
||||
|
||||
@@ -221,7 +282,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,12 +29,24 @@ 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):
|
||||
if not re.search(NOUN,message,re.I):return None
|
||||
message=re.sub(r'^(?:(?:你好|您好)[,,!!。\s]*)+','',message.strip())
|
||||
message=re.sub(r'^(?:或者|那么|那)[,,\s]*','',message)
|
||||
message=re.sub(r'^(?:当前|目前|现在)[,,\s]+','',message)
|
||||
has_noun=bool(re.search(NOUN,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
|
||||
if not listing:
|
||||
parts=[part.strip() for part in re.split(r'[,,。;;??]+|以及|还有|并且|和|\band\b',message,flags=re.I) if part.strip()]
|
||||
if len(parts)>1 and all(re.search(COUNT,part,re.I) for part in parts):
|
||||
queries=[parse_query(part if re.search(NOUN,part,re.I) else part+'订单') for part in parts[:5]]
|
||||
for question,query in zip(parts,queries):query['question']=question
|
||||
return {'summaries':queries,'unsupported':len(parts)>5}
|
||||
remaining=message
|
||||
statuses=[];nodes=[]
|
||||
# Unknown/negated logistics conditions must never silently become a total.
|
||||
@@ -47,10 +59,10 @@ def parse_query(message,listing=False):
|
||||
statuses.append(status);remaining=re.sub(re.escape(phrase),'',remaining,flags=re.I)
|
||||
exception=bool(re.search(r'异常|\b(?:exception|abnormal)\b',remaining,re.I))
|
||||
remaining=re.sub(r'物流异常|异常|\b(?:exception|abnormal)\b','',remaining,flags=re.I)
|
||||
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪|\btracking (?:stages?|nodes?)\b',remaining,re.I))
|
||||
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|各自|各个|每个|分别|分布|各|都|了|\btracking (?:stages?|nodes?)\b', '',remaining,flags=re.I)
|
||||
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪|在哪|\btracking (?:stages?|nodes?)\b',remaining,re.I))
|
||||
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|在哪里|在哪儿|在哪|各自|各个|每个|分别|分布|各|都|了|\btracking (?:stages?|nodes?)\b', '',remaining,flags=re.I)
|
||||
remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I)
|
||||
remaining=re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
|
||||
remaining=re.sub(r'其中|当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
|
||||
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|列出|列表|显示|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
|
||||
r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently|at|each)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
|
||||
return {'status':statuses[0] if statuses else None,'stage':nodes[0] if nodes else 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):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Compare calculable CNY costs on the same destination and billing basis."""
|
||||
|
||||
from decimal import Decimal,ROUND_HALF_UP
|
||||
|
||||
from .quote_fees import charge_details
|
||||
|
||||
|
||||
def speed_difference_lines(intent, book, rates, fees):
|
||||
from .quote_engine import quantity, number, select_timed_rates
|
||||
|
||||
groups = {}
|
||||
for rate in rates:
|
||||
key = (rate['origin'], rate['zone'], rate['kind'], rate['unit'], rate['tax'])
|
||||
groups.setdefault(key, []).append(rate)
|
||||
lines = []
|
||||
for (origin, zone, kind, unit, tax), candidates in groups.items():
|
||||
costs = []
|
||||
for selected in (select_timed_rates(candidates), select_timed_rates(candidates, slowest=True)):
|
||||
values = []
|
||||
for rate in selected:
|
||||
amount = quantity(intent, rate)
|
||||
if amount is not None:
|
||||
total=amount * Decimal(rate['price']) + charge_details(intent, rate, fees, book)['cny']
|
||||
values.append(total.quantize(Decimal('.01'),rounding=ROUND_HALF_UP))
|
||||
costs.append(values)
|
||||
fast, slow = costs
|
||||
if not fast or not slow:
|
||||
continue
|
||||
low, high = min(fast) - max(slow), max(fast) - min(slow)
|
||||
label = ' · '.join(value for value in (origin, zone, '包税' if tax == 'included' else '不包税',
|
||||
'海派' if kind == 'parcel' else '海卡/海铁') if value)
|
||||
difference = number(low) if low == high else number(low) + '至' + number(high)
|
||||
lines.append(f'- {label},快慢方案可计算运费差额为{difference}元。')
|
||||
return lines
|
||||
@@ -3,13 +3,168 @@
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
|
||||
from .quote_intent import kg,MISSING_WEIGHT_UNIT
|
||||
from .quote_edits import ADJUSTMENT_QUESTION,VOLUME_QUESTION
|
||||
|
||||
|
||||
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
|
||||
|
||||
|
||||
def apply_quantity_change(result,current,previous):
|
||||
from .quote_engine import exact_number
|
||||
|
||||
change=current.get('quantity_change')
|
||||
if not change:return False
|
||||
if change['kind']=='volume':
|
||||
updated=Decimal(current['cbm']) if current['cbm'] is not None else None
|
||||
mismatch=False
|
||||
if not change['uncertain'] and previous['cbm'] is not None and not previous.get('volume_pending'):
|
||||
expected=Decimal(previous['cbm'])+Decimal(change['delta'])
|
||||
if updated is not None and updated!=expected:
|
||||
mismatch=True
|
||||
result['errors'].append('增减体积与您提供的新总体积不一致,请确认调整后的总体积。')
|
||||
else:updated=expected
|
||||
if change['uncertain'] or updated is None or updated<=0 or mismatch:
|
||||
result['volume_pending']=True
|
||||
result['errors'].append(VOLUME_QUESTION)
|
||||
else:
|
||||
result['cbm']=str(updated)
|
||||
result['volume_pending']=False
|
||||
result['quantity_note']=f'按本次增减更新,总体积为{exact_number(updated)}CBM。'
|
||||
return False
|
||||
question=ADJUSTMENT_QUESTION
|
||||
result['quantity_question']=question
|
||||
result['quantity_uncertain']=True
|
||||
if change['kind']=='boxes' and (previous['cbm'] is not None or previous.get('volume_pending') or current['cbm'] is not None):
|
||||
result['volume_pending']=True
|
||||
if change['uncertain'] or previous.get('allocations'):
|
||||
result['errors'].append(question)
|
||||
return False
|
||||
if change['kind']=='weight':
|
||||
if previous.get('weight') is None or previous.get('quantity_uncertain') or previous.get('pending_weight'):
|
||||
if current['weight'] is None:
|
||||
result['errors'].append(question)
|
||||
return False
|
||||
updated=Decimal(current['weight'])
|
||||
else:
|
||||
updated=Decimal(previous['weight'])+Decimal(change['delta'])
|
||||
if current['weight'] is not None and Decimal(current['weight'])!=updated:
|
||||
result['errors'].append('增减量与您提供的新总重不一致,请确认调整后的总重。')
|
||||
return False
|
||||
if updated<=0:
|
||||
result['errors'].append('调整后的总重需要大于0,请确认增减量或新的总重。')
|
||||
return False
|
||||
result['weight']=str(updated)
|
||||
else:
|
||||
pieces=deepcopy(previous.get('packages') or [])
|
||||
if not pieces or previous.get('quantity_uncertain') or previous.get('pending_weight'):
|
||||
result['errors'].append(question)
|
||||
return False
|
||||
for piece in pieces:
|
||||
if not piece.get('dims') and previous['dims']:
|
||||
piece.update(dims=previous['dims'],dim_unit=previous['dim_unit'])
|
||||
first=pieces[0]
|
||||
uniform=all(Decimal(piece['weight'])==Decimal(first['weight'])
|
||||
and all(piece.get(key)==first.get(key) for key in ('dims','dim_unit')) for piece in pieces)
|
||||
if uniform:
|
||||
first['count']=sum(piece['count'] for piece in pieces)
|
||||
pieces=[first]
|
||||
delta=change['delta']
|
||||
additions=deepcopy(current.get('packages') or [])
|
||||
target_count=sum(piece['count'] for piece in pieces)+delta
|
||||
declared_count=change.get('declared_boxes')
|
||||
if declared_count is not None and declared_count!=target_count:
|
||||
result['errors'].append('增减箱数与您提供的新总箱数不一致,请确认调整后的箱数。')
|
||||
return False
|
||||
if declared_count is not None and additions and sum(piece['count'] for piece in additions)==target_count:
|
||||
pieces=additions
|
||||
elif delta>0 and additions:
|
||||
if sum(piece['count'] for piece in additions)!=delta:
|
||||
result['errors'].append(question)
|
||||
return False
|
||||
pieces.extend(additions)
|
||||
elif uniform:
|
||||
if delta<0 and additions and any(Decimal(piece['weight'])!=Decimal(first['weight'])
|
||||
or piece.get('dims') and (piece['dims']!=first.get('dims') or piece['dim_unit']!=first.get('dim_unit')) for piece in additions):
|
||||
result['errors'].append('减少箱子的重量与原分箱明细不一致,请提供更新后的逐箱明细。')
|
||||
return False
|
||||
first['count']+=delta
|
||||
if first['count']<=0:
|
||||
result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。')
|
||||
return False
|
||||
else:
|
||||
result['errors'].append('原货物包含不同箱规,请提供更新后的逐箱数量和重量。')
|
||||
return False
|
||||
result['packages']=pieces
|
||||
result['boxes']=sum(piece['count'] for piece in pieces)
|
||||
result['weight']=str(sum(Decimal(piece['weight'])*piece['count'] for piece in pieces))
|
||||
result['dims']=None
|
||||
result['cbm']=None
|
||||
if result['boxes']<=0 or any(piece['count']<=0 for piece in pieces):
|
||||
result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。')
|
||||
return False
|
||||
if change.get('declared_weight') is not None and Decimal(change['declared_weight'])!=Decimal(result['weight']):
|
||||
result['errors'].append('增减箱数推算的总重与您提供的新总重不一致,请确认更新后的分箱明细。')
|
||||
return False
|
||||
result['quantity_uncertain']=False
|
||||
result['quantity_question']=None
|
||||
verb='增加' if Decimal(change['delta'])>0 else '减少'
|
||||
delta=exact_number(abs(Decimal(change['delta'])))
|
||||
if change['kind']=='boxes':
|
||||
old_count=sum(piece['count'] for piece in previous['packages'])
|
||||
result['quantity_note']=f'按原{old_count}箱{verb}{delta}箱核算,共{result["boxes"]}箱。'
|
||||
elif previous['weight'] is not None and not previous.get('quantity_uncertain'):
|
||||
result['quantity_note']=f'按原总重{exact_number(previous["weight"])}KG{verb}{delta}KG核算。'
|
||||
else:result['quantity_note']='按您确认的新总重核算。'
|
||||
return True
|
||||
|
||||
|
||||
def merge_intent(current,previous):
|
||||
result=deepcopy(current)
|
||||
excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update'}
|
||||
excluded={'mode','selection','clear_selection','clear_choice','compare_services','choice_pending','excluded_services','quantity_change','tax_pending','volume_pending','quantity_question',
|
||||
'text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'}
|
||||
unit=current.get('weight_unit')
|
||||
pending=previous.get('pending_weight')
|
||||
weight_change=current['weight'] is not None or bool(unit and pending is not None)
|
||||
weight_change=apply_quantity_change(result,current,previous) or weight_change
|
||||
change=current.get('quantity_change')
|
||||
if change and change['kind']=='boxes':excluded.update(('dims','cbm','allocations'))
|
||||
if change and change['kind']=='volume':excluded.add('dims')
|
||||
volume_confirmed=not (change and change['kind']=='boxes') and (current['cbm'] is not None or current['dims']
|
||||
or current.get('packages') and all(piece.get('dims') for piece in current['packages']))
|
||||
if previous.get('volume_pending') and not volume_confirmed:result['volume_pending']=True
|
||||
if result.get('volume_pending'):
|
||||
result['cbm']=None
|
||||
excluded.add('cbm')
|
||||
result['errors'].append(VOLUME_QUESTION)
|
||||
if unit:
|
||||
if pending is None:result['errors'].append('请同时提供重量数值和单位,例如800KG。')
|
||||
else:result['weight']=str(kg(pending,unit))
|
||||
elif pending is not None and current['weight'] is None and current.get('pending_weight') is None:
|
||||
result['pending_weight']=pending
|
||||
result['errors'].append(MISSING_WEIGHT_UNIT)
|
||||
if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice'):
|
||||
result['selection']=previous.get('selection')
|
||||
if current.get('clear_selection'):
|
||||
excluded.update(('services','kinds','transport','air_only','air_requested'))
|
||||
if current.get('compare_services'):excluded.add('services')
|
||||
blocked=[] if current.get('clear_selection') else previous.get('excluded_services') or []
|
||||
blocked=list(dict.fromkeys(blocked+(current.get('excluded_services') or [])))
|
||||
if current.get('excluded_services'):
|
||||
excluded.add('services')
|
||||
if any(name in ('海派','海卡','海铁') for name in current['excluded_services']):excluded.add('kinds')
|
||||
positives=(current.get('services') or [])+[name for name in ('海派','海卡','海铁') if name in current['text']]
|
||||
result['excluded_services']=[name for name in blocked if not any(name in choice or choice in name for choice in positives)] or None
|
||||
if current.get('tax_pending') or previous.get('tax_pending') and current['tax'] is None:
|
||||
result['tax_pending']=True
|
||||
result['tax']=None
|
||||
excluded.add('tax')
|
||||
result['errors'].append('请明确选择包税或不包税方案。')
|
||||
if current.get('choice_pending') or (previous.get('choice_pending') and not current.get('selection')
|
||||
and not current.get('services') and not current.get('clear_selection')):
|
||||
result['choice_pending']=True
|
||||
result['selection']=None
|
||||
result['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。')
|
||||
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
|
||||
result['metal_chargeable_weight']=None
|
||||
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
|
||||
@@ -26,12 +181,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:
|
||||
@@ -41,13 +196,14 @@ def merge_intent(current,previous):
|
||||
if current['dims'] is None and 'dims' not in excluded and previous['dims']:
|
||||
result['dim_unit']=previous['dim_unit']
|
||||
pieces=result.get('packages') or []
|
||||
if current['dims']:
|
||||
if current['dims'] and not (change and change['kind']=='boxes'):
|
||||
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
|
||||
elif current['cbm'] is not None:
|
||||
elif (current['cbm'] is not None or change and change['kind']=='volume') and not (change and change['kind']=='boxes'):
|
||||
for piece in pieces:piece.update(dims=None,dim_unit='cm')
|
||||
if previous.get('quantity_uncertain') and 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)
|
||||
result['quantity_question']=previous.get('quantity_question') or QUANTITY_QUESTION
|
||||
result['errors'].append(result['quantity_question'])
|
||||
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
|
||||
if current.get('preserve_total'):
|
||||
result['packages']=None
|
||||
@@ -59,4 +215,5 @@ def merge_intent(current,previous):
|
||||
result['packages']=None
|
||||
result['quantity_uncertain']=True
|
||||
result['errors'].append(QUANTITY_QUESTION)
|
||||
result['errors']=list(dict.fromkeys(result['errors']))
|
||||
return result
|
||||
|
||||
@@ -3,11 +3,82 @@
|
||||
import re
|
||||
|
||||
|
||||
SERVICE_TERMS = ('美森正班','美森加班','美森','EXX','合德','普船','海派','海卡','海铁')
|
||||
CHANGE_VERBS = r'增加|追加|加上|加|减少|减掉|减去|减|去掉'
|
||||
ADJUSTMENT_QUESTION = '请提供调整后的总重和箱数,或逐箱重量明细,我再继续核算。'
|
||||
VOLUME_QUESTION = '请确认调整后的总体积,或提供更新后的逐箱尺寸,我再核算体积重。'
|
||||
|
||||
|
||||
def condition_edits(text):
|
||||
rejected = r'(?:不是|不要|并非|不从|不走|别走|不用|不选|别选)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到|走|选|用|按)?\s*'
|
||||
service = r'(?:(?:洛杉矶|纽约|芝加哥|休斯顿|萨凡纳)?(?:美森正班|美森加班|美森|EXX|合德|普船)(?:海派|海卡|海铁|全卡)?|海派|海卡|海铁)'
|
||||
value = (service+r'|不包税|包税|最快|最慢|最便宜|'
|
||||
r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|'
|
||||
r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品')
|
||||
excluded = []
|
||||
taxes = set()
|
||||
clear_choice = False
|
||||
|
||||
def discard(match):
|
||||
nonlocal clear_choice
|
||||
if re.match(r'\s*(?:的)?(?:吗|嘛|么|[??])', text[match.end():]):
|
||||
return match[0]
|
||||
condition = match['value']
|
||||
term = next((name for name in SERVICE_TERMS if name.lower() in condition.lower()), None)
|
||||
if term:
|
||||
excluded.append(term)
|
||||
if condition in ('包税','不包税'):
|
||||
taxes.add(condition)
|
||||
if condition in ('最快','最慢','最便宜'):
|
||||
clear_choice = True
|
||||
return ' '
|
||||
|
||||
cleaned = re.sub(rejected+'(?P<value>'+value+')', discard, text, flags=re.I)
|
||||
tax = 'included' if taxes == {'不包税'} else 'excluded' if taxes == {'包税'} else None
|
||||
return {'text':cleaned, 'excluded_services':list(dict.fromkeys(excluded)) or None,
|
||||
'tax':tax, 'tax_pending':len(taxes)>1, 'clear_choice':clear_choice}
|
||||
|
||||
|
||||
def clean_conditions(text):
|
||||
rejected=r'(?:不是|不要|并非|不从)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到)?\s*'
|
||||
value=(r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|'
|
||||
r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品')
|
||||
return re.sub(rejected+'(?:'+value+')',' ',text,flags=re.I)
|
||||
return condition_edits(text)['text']
|
||||
|
||||
|
||||
def quantity_adjustment(text):
|
||||
"""Separate a relative increment from an explicitly supplied new total."""
|
||||
from .quote_intent import WEIGHT,kg,normalize
|
||||
|
||||
text=normalize(text)
|
||||
prefix = re.match(r'^(?:(?:那(?:么)?|我(?:们)?(?:想|要)?|这次|现在|请帮我|帮我|麻烦|请|总重|重量|箱数|总体积|体积)[,,\s]*)*'
|
||||
r'(?:再)?('+CHANGE_VERBS+r')\s*', text)
|
||||
if not prefix:
|
||||
unmatched=re.search(r'(?:'+CHANGE_VERBS+r')\s*-?\d+(?:\.\d+)?\s*(KG|公斤|千克|吨|箱|件|CBM|立方米|立方|方)',text,re.I)
|
||||
if unmatched:
|
||||
kind='volume' if unmatched[1].lower() in ('cbm','立方米','立方','方') else 'unknown'
|
||||
return {'kind':kind,'delta':None,'text':text,'uncertain':True}
|
||||
return None
|
||||
subtract = prefix[1] in ('减少','减掉','减去','减','去掉')
|
||||
sign = -1 if subtract else 1
|
||||
weight = WEIGHT.match(text, prefix.end())
|
||||
boxes = re.match(r'(\d+)\s*(?:个)?(?:箱|件)', text[prefix.end():])
|
||||
volume = re.match(r'(-?\d+(?:\.\d+)?)\s*(?:CBM|立方米|立方|方)',text[prefix.end():],re.I)
|
||||
if weight:
|
||||
remainder = text[weight.end():]
|
||||
return {'kind':'weight', 'delta':str(sign*kg(*weight.groups())),
|
||||
'text':remainder, 'uncertain':kg(*weight.groups())<=0 or bool(re.search(CHANGE_VERBS+r'\s*\d',remainder))}
|
||||
if boxes:
|
||||
total_weight=re.search(r'(?:新总重|总重量|总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
|
||||
total_boxes=re.search(r'(?:一共|总共|合计|总计|共)\s*(\d+)\s*(?:箱|件)|总箱数\s*(?:是|为)?\s*(\d+)',text)
|
||||
return {'kind':'boxes', 'delta':sign*int(boxes[1]), 'text':text, 'uncertain':int(boxes[1])<=0,
|
||||
'declared_weight':str(kg(*total_weight.groups())) if total_weight else None,
|
||||
'declared_boxes':int(total_boxes[1] or total_boxes[2]) if total_boxes else None}
|
||||
if volume:
|
||||
from decimal import Decimal
|
||||
amount=Decimal(volume[1])
|
||||
return {'kind':'volume','delta':str(sign*amount),'text':text[prefix.end()+volume.end():],
|
||||
'uncertain':amount<=0}
|
||||
if re.match(r'-?\d', text[prefix.end():]):
|
||||
return {'kind':'unknown', 'delta':None, 'text':text, 'uncertain':True}
|
||||
return None
|
||||
|
||||
|
||||
def weight_comparison(text):
|
||||
|
||||
@@ -93,6 +93,8 @@ def match_rates(intent, book, variant):
|
||||
continue
|
||||
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
|
||||
continue
|
||||
if any(name in rate['service'] for name in intent.get('excluded_services') or []):
|
||||
continue
|
||||
if intent['tax'] and rate['tax'] != intent['tax']:
|
||||
continue
|
||||
if rate['kind'] == 'truck':
|
||||
@@ -127,11 +129,31 @@ def quantity_number(value,intent):
|
||||
return '约'+number(value,6)
|
||||
|
||||
|
||||
def select_timed_rates(rates,slowest=False,by_origin=False):
|
||||
"""Compare numeric day ranges within one region, service kind and billing basis."""
|
||||
ranked=[]
|
||||
best={}
|
||||
for rate in rates:
|
||||
match=re.search(r'(?<![\d.])(\d+(?:\.\d+)?)\s*(?:[-—–~~至]\s*(\d+(?:\.\d+)?))?\s*(?:天|days?)',rate['eta'],re.I)
|
||||
if not match:continue
|
||||
low=Decimal(match[1])
|
||||
high=Decimal(match[2] or match[1])
|
||||
if low<=0 or high<low:continue
|
||||
key=(rate['zone'],rate['kind'],rate['unit'],rate['tax'])
|
||||
if by_origin:key+=(rate['origin'],)
|
||||
timing=(high,low) if slowest else (low,high)
|
||||
ranked.append((rate,key,timing))
|
||||
choose=max if slowest else min
|
||||
best[key]=choose(best.get(key,timing),timing)
|
||||
return [rate for rate,key,timing in ranked if timing==best[key]]
|
||||
|
||||
|
||||
def render_rates(intent, book, destinations, remote_fees):
|
||||
from .quote_fees import charge_details
|
||||
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']:
|
||||
@@ -154,37 +176,45 @@ 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') in ('cheapest','fastest') and rates:
|
||||
if intent['mode']=='cheapest':
|
||||
eligible=[r for r in rates if quantity(billing,r) is not None]
|
||||
best={}
|
||||
for rate in eligible:
|
||||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
|
||||
best[key]=min(best.get(key,value),value)
|
||||
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||||
else:
|
||||
def eta_key(rate):
|
||||
values=[Decimal(v) for v in re.findall(r'\d+(?:\.\d+)?',rate['eta'])]
|
||||
return tuple(values[:2]) if values else (Decimal('Infinity'),)
|
||||
earliest=min(eta_key(r) for r in rates)
|
||||
rates=[r for r in rates if eta_key(r)==earliest]
|
||||
lines.append('按资料中的参考时效,较快方案如下;提取不等于送达或签收:')
|
||||
if rate_selection=='cheapest' and rates:
|
||||
eligible=[r for r in rates if quantity(billing,r) is not None]
|
||||
best={}
|
||||
for rate in eligible:
|
||||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
|
||||
best[key]=min(best.get(key,value),value)
|
||||
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||||
if not rates:
|
||||
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
|
||||
continue
|
||||
if destination['label'] != '美国':
|
||||
lines.append(destination['label']+':')
|
||||
selections=[('',rates)]
|
||||
if rate_selection in ('fastest','slowest','speed_compare'):
|
||||
selections=[]
|
||||
if rate_selection in ('fastest','speed_compare'):
|
||||
selections.append(('最快参考方案',select_timed_rates(rates,by_origin=intent.get('mode')=='difference')))
|
||||
if rate_selection in ('slowest','speed_compare'):
|
||||
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True,by_origin=intent.get('mode')=='difference')))
|
||||
lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
|
||||
grouped = {}
|
||||
for rate in rates:
|
||||
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
|
||||
grouped.setdefault(key, []).append(rate)
|
||||
for (service, origin, unit, tax), rows in grouped.items():
|
||||
for selection,selected_rates in selections:
|
||||
if selection and not selected_rates:
|
||||
lines.append(selection+'暂时无法确定,匹配线路未列明可比较的天数。')
|
||||
for rate in selected_rates:
|
||||
key = (selection, rate['service'], rate['origin'], rate['unit'], rate['tax'], rate['eta'])
|
||||
grouped.setdefault(key, []).append(rate)
|
||||
last_selection=None
|
||||
for (selection,service, origin, unit, tax,eta_source), rows in grouped.items():
|
||||
if selection and selection!=last_selection:
|
||||
lines.append(selection)
|
||||
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=rows[0]['eta'] 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:
|
||||
@@ -203,11 +233,16 @@ 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*无时效赔偿', '', rows[0]['eta']).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 intent.get('mode')=='difference':
|
||||
from .quote_comparison import speed_difference_lines
|
||||
differences=speed_difference_lines(billing,book,rates,fees)
|
||||
if differences:
|
||||
lines.append('以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去最慢方案。')
|
||||
lines.extend(differences)
|
||||
else:
|
||||
lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。')
|
||||
if fees:
|
||||
if fees['fee'] is None:
|
||||
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
|
||||
@@ -216,7 +251,9 @@ 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('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。')
|
||||
if re.search(r'哪天|几号|什么时候|何时|收到',intent['text']):
|
||||
lines.append('请补充预计开船日期和目的仓码或邮编,再核对具体到货日期。')
|
||||
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
|
||||
lines.append('货物多少公斤、几箱?')
|
||||
elif not intent['boxes'] or not intent['dims']:
|
||||
|
||||
@@ -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,17 @@ def kg(value,unit):
|
||||
return value
|
||||
|
||||
|
||||
def request_selection(text):
|
||||
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text) and re.search(r'最快|最慢|快的|慢的',text):return 'speed_compare'
|
||||
if re.search(r'最便宜|便宜的|便宜一?点|更便宜|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
|
||||
fastest=bool(re.search(r'最快|快的|更快|快一?点|fastest',text,re.I))
|
||||
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
|
||||
if fastest and slowest:return 'speed_compare'
|
||||
if fastest:return 'fastest'
|
||||
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,27 +75,30 @@ def request_mode(text):
|
||||
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
|
||||
from .shipping_faq import preparation_topic
|
||||
if preparation_topic(text):return 'preparation'
|
||||
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
|
||||
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text):return 'total'
|
||||
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
|
||||
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text):return 'difference'
|
||||
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价|总价多少|这个多少钱|那个多少钱',text) or re.fullmatch(r'(?:那)?一共(?:呢|多少)?[。.!!??\s]*',text):return 'total'
|
||||
if re.fullmatch(r'第[一二三四五六七八九十\d]+(?:个|条|种)(?:方案|渠道)(?:呢)?[。.!!??\s]*',text):return 'clarify_route'
|
||||
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
|
||||
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
|
||||
if re.search(r'附加费|含税|含派送|全包|包不包|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
|
||||
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
|
||||
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
|
||||
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
|
||||
if re.search(r'时效|多久|几天',text) and not WEIGHT.search(text):return 'time'
|
||||
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):
|
||||
text=normalize(message)
|
||||
from .quote_edits import clean_conditions
|
||||
text=clean_conditions(text)
|
||||
from .quote_edits import condition_edits,quantity_adjustment,ADJUSTMENT_QUESTION,VOLUME_QUESTION
|
||||
edits=condition_edits(text)
|
||||
text=edits['text']
|
||||
text=re.sub(r'(?:而?不是|并非|不要按|不是按)\s*(?:每[箱件]|单[箱件])?\s*'+WEIGHT.pattern,' ',text,flags=re.I)
|
||||
from .quote_material import material_intent
|
||||
materials=material_intent(text,WEIGHT,kg)
|
||||
quantity_text=materials['quantity_text']
|
||||
change=quantity_adjustment(quantity_text)
|
||||
if change:quantity_text=change['text']
|
||||
parsed=parse_shipping_intent(quantity_text)
|
||||
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
|
||||
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
|
||||
@@ -86,6 +106,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','×','*'))]
|
||||
@@ -112,12 +136,13 @@ def parse_intent(message,warehouse_codes):
|
||||
if packages:
|
||||
package_count=sum(p['count'] for p in packages)
|
||||
aggregate_count=boxes and not any(m.start()<=boxes.start()<m.end() for m in matches)
|
||||
if count and aggregate_count and count!=package_count:
|
||||
if count and aggregate_count and count!=package_count and not (change and change['kind']=='boxes'):
|
||||
errors.append('箱数与分箱明细不一致,请确认总箱数及每箱重量。')
|
||||
count=package_count
|
||||
weight=str(sum(Decimal(p['weight'])*p['count'] for p in packages))
|
||||
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
|
||||
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
|
||||
if total and kg(*total.groups())!=Decimal(weight) and not (change and change['kind']=='boxes'):
|
||||
errors.append('总重与分箱重量合计不一致,请确认。')
|
||||
# Per-carton dimensions must not spill onto another carton without dimensions.
|
||||
if len(packages)>1:parsed['dims']=None
|
||||
elif assigned:
|
||||
@@ -140,7 +165,7 @@ def parse_intent(message,warehouse_codes):
|
||||
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
|
||||
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
|
||||
if match:postal=match.group(1)
|
||||
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
|
||||
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',quantity_text,re.I)
|
||||
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
|
||||
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
|
||||
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
|
||||
@@ -154,14 +179,26 @@ 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'],
|
||||
'air_only':True if air and not sea else None,'air_requested':True if air else None,
|
||||
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
|
||||
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
|
||||
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
|
||||
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
|
||||
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else edits['tax']),
|
||||
'excluded_services':edits['excluded_services'],'clear_choice':edits['clear_choice'],
|
||||
'compare_services':bool(re.search(r'更快|快一?点|更便宜|便宜一?点',text)),
|
||||
'choice_pending':edits['clear_choice'] and request_selection(text) is None and not services,
|
||||
'tax_pending':edits['tax_pending'],'quantity_change':change,
|
||||
'quantity_uncertain':bool(change and change['kind']!='volume'),
|
||||
'volume_pending':bool(change and change['kind']=='volume'),
|
||||
'quantity_question':(VOLUME_QUESTION if change['kind']=='volume' else ADJUSTMENT_QUESTION) if change else None,
|
||||
'errors':errors,'mode':mode,'selection':request_selection(text),
|
||||
'pending_weight':pending_weight[1] if pending_weight else None,
|
||||
'weight_unit':weight_unit[1] if weight_unit else None,
|
||||
'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'],
|
||||
@@ -170,14 +207,21 @@ def parse_intent(message,warehouse_codes):
|
||||
|
||||
|
||||
def is_followup(message,intent):
|
||||
if resumes_quote(message):return True
|
||||
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
|
||||
if TOPIC_BOUNDARY.search(message):return False
|
||||
if intent.get('weight_unit') or intent.get('quantity_change'):return True
|
||||
if intent.get('excluded_services') or intent.get('clear_choice') or intent.get('compare_services') or intent.get('tax_pending'):
|
||||
return not (intent['country'] and intent['weight'])
|
||||
text=message.strip().strip(',,。.!!?? ')
|
||||
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
|
||||
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
|
||||
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
|
||||
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
|
||||
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin','zone','material','metal_scope','metal_chargeable_weight')))
|
||||
|
||||
@@ -15,9 +15,13 @@ 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:'),
|
||||
('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。','Reference times are compared within the same region, service kind, billing unit and tax basis. Fastest uses the lower day bound and slowest uses the upper day bound.'),
|
||||
('暂时无法确定,匹配线路未列明可比较的天数。',' cannot be determined because matching routes have no comparable numeric day estimates.'),
|
||||
('最快参考方案','Fastest reference options'),('最慢参考方案','Slowest reference options'),
|
||||
('按资料中的参考时效,较快方案如下;提取不等于送达或签收:','Faster reference options follow. Pickup timing is not a delivery or receipt commitment:'),
|
||||
('该邮编属于遥远地址,派送附加费需单独确认。','This ZIP code is extremely remote; the delivery surcharge requires confirmation.'),
|
||||
('海派已计入偏远附加费','Sea parcel totals include the remote surcharge of '),
|
||||
|
||||
@@ -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,9 @@ 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('quantity_change') and not earlier_intents:
|
||||
intent['errors'].append(intent['quantity_question'])
|
||||
if intent.get('metal_weight_update') and not earlier_intents:
|
||||
intent['metal_chargeable_weight']=None
|
||||
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
|
||||
@@ -64,11 +85,31 @@ def answer_quote(message, conv_id):
|
||||
return None
|
||||
intent = intent_with_context(message, conv_id, book)
|
||||
if intent['country'] != 'US':
|
||||
if intent.get('quantity_change'):
|
||||
if intent['quantity_change']['kind']=='volume':
|
||||
return {'t':'请先提供原来的总体积和目的国家、目的仓,再说明增减量。'}
|
||||
return {'t':'请先提供原来的总重、箱数和目的国家,再说明增减量。'}
|
||||
if resumes_quote(message):
|
||||
return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'}
|
||||
if intent.get('mode')=='clarify_route':
|
||||
return {'t':'请先提供目的国家、重量和所需渠道,再继续核对具体方案。'}
|
||||
if intent.get('weight_unit'):
|
||||
return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'}
|
||||
if intent['country'] is None and intent.get('mode')=='preparation':
|
||||
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
|
||||
return None
|
||||
if intent.get('tax_pending') and '请明确选择包税或不包税方案。' not in intent['errors']:
|
||||
intent['errors'].append('请明确选择包税或不包税方案。')
|
||||
if intent.get('choice_pending') and '请指定希望使用的渠道,或选择最快、最慢、最便宜方案。' not in intent['errors']:
|
||||
intent['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。')
|
||||
if intent.get('errors'):
|
||||
return {'t':'\n'.join(intent['errors'])}
|
||||
if intent.get('mode')=='clarify_route':
|
||||
return {'t':'请指定方案名称,例如美森正班或普船,或说“最快”“最慢”“最便宜”,我按当前货物条件继续核算。'}
|
||||
if intent.get('mode')=='difference':
|
||||
if intent.get('selection') not in ('fastest','slowest','speed_compare'):
|
||||
return {'t':'请明确要比较的方案,可直接问“最快和最慢相差多少”,我按当前货物条件核算差额。'}
|
||||
intent['selection']='speed_compare'
|
||||
if intent.get('unknown_warehouses'):
|
||||
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
|
||||
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const test = require('node:test')
|
||||
|
||||
const root = resolve(__dirname, '../..')
|
||||
const wrapper = join(root, 'deploy/build-image.sh')
|
||||
const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
|
||||
const shellAvailable = process.platform !== 'win32' || existsSync(bash)
|
||||
|
||||
// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。
|
||||
function runBuild(availableMemory, childStatus = '0') {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-'))
|
||||
try {
|
||||
writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 })
|
||||
writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 })
|
||||
writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 })
|
||||
return spawnSync(bash, ['-c',
|
||||
'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"',
|
||||
'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'),
|
||||
'--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], {
|
||||
encoding: 'utf8', timeout: 10000,
|
||||
env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus }
|
||||
})
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
|
||||
rmSync(directory, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('all image targets use the resource gate', () => {
|
||||
const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8')
|
||||
for (const target of ['test', 'production', 'certbot']) {
|
||||
assert.ok(pipeline.includes(`sh deploy/build-image.sh --target ${target} `))
|
||||
}
|
||||
assert.doesNotMatch(pipeline, /docker build\s/)
|
||||
assert.match(pipeline, /timeout\(time: 30, unit: 'MINUTES'\)/)
|
||||
})
|
||||
|
||||
test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => {
|
||||
const result = runBuild('4194304', '23')
|
||||
assert.equal(result.status, 23, result.stderr)
|
||||
assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/)
|
||||
assert.match(result.stdout, /builder=0\r?\nbuild/)
|
||||
for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) {
|
||||
assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument)
|
||||
}
|
||||
assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./)
|
||||
assert.equal(runBuild('8388608').status, 0)
|
||||
assert.equal(runBuild('8388608', '124').status, 124)
|
||||
})
|
||||
|
||||
test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => {
|
||||
for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) {
|
||||
const result = runBuild(memory)
|
||||
assert.equal(result.status, status, result.stderr)
|
||||
assert.equal(result.stdout, '')
|
||||
}
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -121,6 +123,240 @@ class OrderStatisticsTests(unittest.TestCase):
|
||||
text=self.ask('已清关的异常订单有多少')['t']
|
||||
self.assertIn('1 票当前节点为“已清关、可提柜”',text);self.assertIn('等待客户资料',text)
|
||||
|
||||
def test_combined_counts_answer_each_question_and_expand_the_last_filter(self):
|
||||
question='我的订单有多少个,异常有多少个'
|
||||
with patch('logistics_chat.order_listing.read_orders') as read:
|
||||
self.assertTrue(self.ask(question)['login_required'])
|
||||
self.assertTrue(self.ask('异常的有多少条?')['login_required'])
|
||||
read.assert_not_called()
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
text=self.ask(question,cid)['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('7 票明确标记异常',text)
|
||||
self.assertEqual(text.count('- 入仓码:'),5)
|
||||
self.assertEqual(text.count('异常原因:'),5)
|
||||
self.assertIn('共7票',self.ask('查看全部',cid)['t'])
|
||||
self.assertIn('7 票明确标记异常',self.ask('异常的有多少条?',cid)['t'])
|
||||
self.assertIn('共7票',self.ask('查看全部',cid)['t'])
|
||||
|
||||
def test_spoken_count_questions_tolerate_greetings_and_locate_all_current_nodes(self):
|
||||
self.login()
|
||||
text=self.ask('你好你好,现在我的订单在哪里了?有多少个?')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('船舶已到目的港:10票',text)
|
||||
self.assertIn('暂无实际节点:3票',text)
|
||||
self.assertIn('共有 16 票订单',self.ask('或者订单有多少条?')['t'])
|
||||
|
||||
def test_combined_statistics_keep_node_distribution_before_exception_count(self):
|
||||
self.login()
|
||||
text=self.ask('我的订单各个轨迹节点分别有多少,异常的有多少条')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('船舶已到目的港:10票',text)
|
||||
self.assertIn('已清关、可提柜:2票',text)
|
||||
self.assertIn('暂无实际节点:3票',text)
|
||||
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_adjacent_order_navigation_keeps_the_displayed_rows_and_current_selection(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
text=self.ask('我有多少异常订单',cid)['t']
|
||||
shown=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)
|
||||
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],shown[0][1])
|
||||
self.assertEqual(self.ask('下一票呢',cid)['card']['no'],shown[1][1])
|
||||
self.ask('谢谢',cid)
|
||||
self.assertEqual(self.ask('上一票的轨迹',cid)['card']['no'],shown[0][1])
|
||||
self.assertIn('未登记申报毛重',self.ask('那这个有多重',cid)['t'])
|
||||
self.assertEqual(self.ask('下一票的轨迹',cid)['card']['no'],shown[1][1])
|
||||
|
||||
def test_adjacent_navigation_stops_at_visible_edges_and_resets_on_page_changes(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
order_data.import_records(c,[{'tenant_id':'a','order_no':f'PAGEX2026{i:04}','inbound_code':f'PAGEIN2026{i:04}',
|
||||
'status':'PROCESSING'} for i in range(30)])
|
||||
c.commit();c.close()
|
||||
self.ask('列出我的全部订单',cid)
|
||||
self.ask('第20票的轨迹',cid)
|
||||
self.assertIn('最后一票',self.ask('下一票呢',cid)['t'])
|
||||
text=self.ask('下一页',cid)['t']
|
||||
first=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)[0][1]
|
||||
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],first)
|
||||
self.assertIn('第一票',self.ask('上一票呢',cid)['t'])
|
||||
|
||||
def test_adjacent_navigation_needs_an_owned_list_and_a_selected_row(self):
|
||||
for question in ('下一票呢','上一票的轨迹'):
|
||||
self.assertTrue(self.ask(question)['login_required'])
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t'])
|
||||
self.ask('我有多少异常订单',cid)
|
||||
self.assertIn('先在当前列表中选择',self.ask('下一票呢',cid)['t'])
|
||||
self.ask('第一个呢',cid)
|
||||
self.client.post('/api/customer/logout')
|
||||
self.assertTrue(self.ask('下一票呢',cid)['login_required'])
|
||||
|
||||
def test_navigation_and_weights_do_not_reuse_orders_after_a_quote_topic(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('我有多少异常订单',cid)
|
||||
self.ask('第一个呢',cid)
|
||||
self.ask('美国30KG海运报价',cid)
|
||||
self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t'])
|
||||
|
||||
def test_implicit_count_without_an_established_range_asks_for_the_missing_scope(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
text=self.ask('异常的呢',cid)['t']
|
||||
self.assertIn('说明要统计的订单范围',text)
|
||||
self.assertNotIn('7 票',text)
|
||||
|
||||
def test_list_selection_keeps_the_order_when_its_old_inbound_code_is_reassigned(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
order_data.import_records(c,[{'tenant_id':'a','order_no':'ORIGINAL20260001','inbound_code':'REUSEDINBOUND1',
|
||||
'status':'PROCESSING','source_updated_at':'2030-01-01','exception_reason':'原订单资料'},
|
||||
{'tenant_id':'a','order_no':'OTHER20260001','status':'PROCESSING',
|
||||
'source_updated_at':'2029-01-01','exception_reason':'另一票资料'}])
|
||||
c.commit();c.close()
|
||||
self.ask('我有多少异常订单',cid)
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
c.execute("UPDATE order_test_data SET inbound_code='NEWINBOUND1' WHERE tenant_id='a' AND order_no='ORIGINAL20260001'")
|
||||
c.execute("UPDATE order_test_data SET inbound_code='REUSEDINBOUND1' WHERE tenant_id='a' AND order_no='OTHER20260001'")
|
||||
c.commit();c.close()
|
||||
card=self.ask('第一个呢',cid)['card']
|
||||
self.assertEqual(card['no'],'ORIGINAL20260001')
|
||||
self.assertIn({'key':'inbound','value':'NEWINBOUND1'},card['fields'])
|
||||
self.assertIn('原订单资料',self.ask('异常原因呢',cid)['t'])
|
||||
self.assertEqual(self.ask('下一票呢',cid)['card']['no'],'OTHER20260001')
|
||||
|
||||
def test_adjacent_selection_follows_actual_cards_after_overlapping_requests(self):
|
||||
from logistics_chat.tracking import CARD_PREFIX
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('我有多少异常订单',cid)
|
||||
second=self.ask('第二个呢',cid)['card']
|
||||
self.ask('第一个呢',cid)
|
||||
with self.app.app_context():
|
||||
for _ in range(2):
|
||||
db.save_message(cid,'user','下一票呢')
|
||||
db.save_message(cid,'assistant',CARD_PREFIX+json.dumps(second,ensure_ascii=False))
|
||||
third=self.ask('下一票呢',cid)['card']['no']
|
||||
self.assertEqual(third,self.ask('第三个呢',cid)['card']['no'])
|
||||
|
||||
def test_ambiguous_speech_answers_the_clear_count_and_requests_the_missing_meaning(self):
|
||||
self.login()
|
||||
text=self.ask('当前,我的订单有多少条,一层有多少条?')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('“一层有多少条”的筛选条件尚未明确',text)
|
||||
self.assertNotIn('7 票明确标记异常',text)
|
||||
text=self.ask('我的订单有多少个,今天异常订单有多少个')['t']
|
||||
self.assertIn('共有 16 票订单',text)
|
||||
self.assertIn('“今天异常订单有多少个”的筛选条件尚未明确',text)
|
||||
|
||||
def test_distribution_counts_each_order_once_and_never_uses_forecasts_or_business_completion(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)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Relative quantity and condition edits keep the previously confirmed quote basis."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app,db
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from test_quote_timing import timing_snapshot
|
||||
from test_quotes import frames
|
||||
|
||||
|
||||
class QuoteChangeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'5'*64})
|
||||
self.cid=self.client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context():
|
||||
c=db.connect();import_snapshot(c,timing_snapshot(),activate=True);c.commit();c.close()
|
||||
|
||||
def ask(self,question):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':self.cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
self.assertFalse(any(event.get('e') for event in events),events)
|
||||
return events[0]['t']
|
||||
|
||||
def test_weight_increments_are_added_and_absolute_changes_replace_the_total(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
self.assertIn('1100KG = 6600元',self.ask('再加100KG,其他不变'))
|
||||
self.assertIn('900KG = 5400元',self.ask('再减掉200KG'))
|
||||
self.assertIn('900KG = 5400元',self.ask('那一共多少钱'))
|
||||
self.assertIn('800KG = 4800元',self.ask('减到800KG'))
|
||||
self.assertIn('900KG = 5400元',self.ask('增加0.1吨,总重900KG'))
|
||||
|
||||
def test_invalid_or_unitless_changes_stay_pending_until_a_new_total_is_confirmed(self):
|
||||
self.ask('美国30KG义乌美西普船海派报价')
|
||||
self.assertIn('调整后的总重需要大于0',self.ask('减去40KG'))
|
||||
text=self.ask('换成美东')
|
||||
self.assertIn('请提供调整后的总重',text)
|
||||
self.assertNotIn('KG =',text)
|
||||
self.assertIn('20KG = 120元',self.ask('总重20KG'))
|
||||
self.assertIn('请提供调整后的总重',self.ask('再加100'))
|
||||
self.assertNotIn('KG =',self.ask('具体多少'))
|
||||
self.assertIn('50KG = 300元',self.ask('新总重50KG'))
|
||||
|
||||
def test_relative_quantity_without_a_confirmed_starting_value_requests_a_total(self):
|
||||
self.assertIn('原来的总重',self.ask('再加100KG'))
|
||||
self.assertIn('调整后的总重',self.ask('美国普船海派'))
|
||||
self.assertIn('200KG = 800元',self.ask('总重200KG,义乌美西'))
|
||||
|
||||
def test_uniform_cartons_add_and_remove_without_losing_the_previous_cartons(self):
|
||||
self.ask('美国10箱,每箱20KG,义乌美西普船海派报价')
|
||||
self.assertIn('240KG = 960元',self.ask('再加2箱,每箱20KG'))
|
||||
text=self.ask('减少1箱')
|
||||
self.assertIn('220KG = 880元',text)
|
||||
self.assertIn('共11箱',text)
|
||||
self.assertIn('220KG = 880元',self.ask('那一共呢'))
|
||||
|
||||
def test_added_carton_dimensions_apply_to_the_added_cartons_only(self):
|
||||
self.ask('美国1箱,每箱20KG,30x30x30cm,义乌美西普船海派报价')
|
||||
text=self.ask('再加1箱,每箱10KG,60x50x40cm')
|
||||
self.assertIn('40KG = 160元',text)
|
||||
self.assertNotIn('50KG =',text)
|
||||
|
||||
def test_each_carton_minimum_remains_in_effect_after_addition(self):
|
||||
self.ask('美国2箱,每箱5KG,义乌美西普船海派报价')
|
||||
self.assertIn('36KG = 144元',self.ask('再加1箱,每箱5KG'))
|
||||
|
||||
def test_declared_new_totals_are_validated_after_a_carton_change(self):
|
||||
self.ask('美国10箱每箱20KG,义乌美西普船海派报价')
|
||||
self.assertIn('240KG = 960元',self.ask('再加2箱每箱20KG,总重240KG'))
|
||||
self.assertIn('增减箱数推算的总重',self.ask('再加1箱每箱20KG,总重300KG'))
|
||||
self.assertNotIn('KG =',self.ask('换成华南'))
|
||||
self.assertIn('300KG',self.ask('总重300KG,共13箱'))
|
||||
|
||||
def test_heterogeneous_carton_removal_requires_the_updated_breakdown(self):
|
||||
self.ask('美国1箱10KG,1箱30KG,义乌美西普船海派报价')
|
||||
self.assertIn('不同箱规',self.ask('减少1箱'))
|
||||
self.assertNotIn('KG =',self.ask('具体多少钱'))
|
||||
self.assertIn('30KG = 120元',self.ask('现在1箱30KG'))
|
||||
|
||||
def test_rejected_services_remain_excluded_until_explicitly_selected_again(self):
|
||||
self.ask('美国1000KG义乌美东海运最便宜报价')
|
||||
text=self.ask('不走普船了,改成美森正班')
|
||||
self.assertIn('1000KG = 10000元',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
text=self.ask('不要美森,走普船')
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
self.assertNotIn('美森',text)
|
||||
self.ask('不走普船')
|
||||
text=self.ask('改成华南')
|
||||
self.assertNotIn('普船海派',text)
|
||||
self.assertNotIn('美森',text)
|
||||
self.assertIn('合德海派',text)
|
||||
self.assertIn('普船海派',self.ask('那还是普船吧'))
|
||||
self.assertIn('美森正班海派',self.ask('全部渠道报价'))
|
||||
|
||||
def test_tax_corrections_and_pending_conflicts_are_not_reversed(self):
|
||||
self.ask('美国1000KG义乌美东海派报价')
|
||||
self.assertIn('1000KG = 6000元',self.ask('不要不包税,我要包税'))
|
||||
self.assertIn('没有匹配',self.ask('不是包税,是不包税'))
|
||||
self.assertIn('1000KG = 6000元',self.ask('那还是包税吧'))
|
||||
self.assertIn('1000KG = 6000元',self.ask('不是包税吗?'))
|
||||
self.assertIn('明确选择包税',self.ask('不要包税,也不要不包税'))
|
||||
self.assertNotIn('KG =',self.ask('换成华南'))
|
||||
self.assertIn('1000KG = 6000元',self.ask('要包税'))
|
||||
|
||||
def test_new_batch_with_rejections_resets_old_shipping_conditions(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
text=self.ask('新的一批美国30KG海派报价,不走普船')
|
||||
self.assertIn('美国 30KG',text)
|
||||
self.assertIn('华南',text)
|
||||
self.assertIn('美西',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
|
||||
def test_calendar_questions_preserve_route_and_request_the_missing_dates(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
for question in ('什么时候能到','大概哪天能收到'):
|
||||
text=self.ask(question)
|
||||
self.assertIn('25-30天左右提取',text)
|
||||
self.assertIn('预计开船日期',text)
|
||||
text=self.ask('快一点呢')
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
text=self.ask('那比慢的贵多少')
|
||||
self.assertIn('运费差额为4000元',text)
|
||||
|
||||
def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self):
|
||||
self.ask('美国1000KG海运最快和最慢报价')
|
||||
self.assertIn('1000KG',self.ask('这个多少钱'))
|
||||
self.assertIn('请指定方案名称',self.ask('第二个方案呢'))
|
||||
self.assertIn('1000KG = 4000元',self.ask('便宜的那个'))
|
||||
self.assertIn('1000KG = 4000元',self.ask('一共呢'))
|
||||
|
||||
def test_tied_speed_costs_produce_a_range_without_mixing_tax_or_units(self):
|
||||
from logistics_chat.quote_comparison import speed_difference_lines
|
||||
from logistics_chat.quote_data import load_book
|
||||
from logistics_chat.quote_intent import parse_intent
|
||||
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'service':name,'price':price,'eta':eta} for name,price,eta in (
|
||||
('快线A','8','12-15天'),('快线B','10','12-15天'),
|
||||
('慢线A','5','30-35天'),('慢线B','6','30-35天'))]
|
||||
rates.append({**base,'service':'自税线','tax':'excluded','price':'999','eta':'20天'})
|
||||
rates.append({**base,'service':'按方线','unit':'cbm','price':'999','eta':'20天'})
|
||||
with self.app.app_context():
|
||||
book=load_book()
|
||||
intent=parse_intent('美国1000KG义乌美东海派报价',set())
|
||||
lines=speed_difference_lines(intent,book,rates,None)
|
||||
self.assertTrue(any(' · 包税 · ' in line and '2000至5000元' in line for line in lines),lines)
|
||||
self.assertTrue(any('不包税' in line and '差额为0元' in line for line in lines),lines)
|
||||
self.assertEqual(len(lines),2)
|
||||
self.assertEqual(speed_difference_lines(intent,book,[{**rates[0],'eta':'未提供时效'}],None),[])
|
||||
|
||||
def test_polite_relative_edits_and_unrecognized_adjustments_do_not_replace_the_total(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
self.assertIn('1100KG = 6600元',self.ask('那我再加一百公斤'))
|
||||
self.assertIn('900KG = 5400元',self.ask('请帮我减少200KG'))
|
||||
self.assertIn('调整后的总重',self.ask('这批货后面增加100KG'))
|
||||
self.assertNotIn('KG =',self.ask('具体多少钱'))
|
||||
|
||||
def test_difference_uses_the_displayed_two_decimal_costs(self):
|
||||
from logistics_chat.quote_comparison import speed_difference_lines
|
||||
from logistics_chat.quote_data import load_book
|
||||
from logistics_chat.quote_intent import parse_intent
|
||||
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'service':'快线','price':'6.4','eta':'12天'},
|
||||
{**base,'service':'慢线','price':'4.3','eta':'30天'}]
|
||||
with self.app.app_context():
|
||||
intent=parse_intent('美国12.555KG义乌美东海派报价',set())
|
||||
text='\n'.join(speed_difference_lines(intent,load_book(),rates,None))
|
||||
self.assertIn('差额为26.36元',text)
|
||||
|
||||
def test_difference_shows_both_speeds_for_each_origin_used_in_the_calculation(self):
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
c.execute("UPDATE quote_rates SET eta='船开后16天左右' WHERE service='美森正班海派' AND origin='华南'")
|
||||
c.commit();c.close()
|
||||
text=self.ask('美国1000KG美东海派最快最慢差价')
|
||||
self.assertIn('美森正班海派 · 义乌',text)
|
||||
self.assertIn('美森正班海派 · 华南',text)
|
||||
self.assertEqual(text.count('运费差额为4000元'),2)
|
||||
|
||||
def test_volume_increments_keep_the_confirmed_total_and_units(self):
|
||||
self.ask('美国2CBM义乌美东普船海派报价')
|
||||
text=self.ask('再加1方')
|
||||
self.assertIn('总体积为3CBM',text)
|
||||
self.assertIn('3000元',text)
|
||||
text=self.ask('总体积减少0.5CBM')
|
||||
self.assertIn('总体积为2.5CBM',text)
|
||||
self.assertIn('2500元',text)
|
||||
|
||||
def test_carton_changes_with_an_aggregate_volume_wait_for_the_updated_volume(self):
|
||||
self.ask('美国2箱每箱20KG,1CBM,义乌美东普船海派报价')
|
||||
self.assertIn('调整后的总体积',self.ask('再加1箱每箱20KG'))
|
||||
text=self.ask('总重60KG')
|
||||
self.assertIn('调整后的总体积',text)
|
||||
self.assertNotIn('KG =',text)
|
||||
text=self.ask('总体积1.5CBM')
|
||||
self.assertIn('1500元',text)
|
||||
|
||||
def test_unrecognized_volume_increment_is_clarified_without_using_it_as_a_new_total(self):
|
||||
self.ask('美国2CBM义乌美东普船海派报价')
|
||||
text=self.ask('这批货后面增加1方')
|
||||
self.assertIn('调整后的总体积',text)
|
||||
self.assertNotIn('元/KG',text)
|
||||
self.assertIn('3000元',self.ask('总体积3CBM'))
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Fast/slow comparisons preserve shipping conditions and comparable rate groups."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from logistics_chat.quote_engine import select_timed_rates
|
||||
from test_quotes import fixture_snapshot, frames
|
||||
|
||||
|
||||
def timing_snapshot():
|
||||
book=fixture_snapshot()
|
||||
rates=[]
|
||||
for origin in ('义乌','华南'):
|
||||
for zone,offset in (('美西',0),('美中',1),('美东',2)):
|
||||
for service,price,eta in (('美森正班海派',8,'船开后15天左右'),
|
||||
('合德海派',6,'船开后20-22天左右'),
|
||||
('普船海派',4,'船开后25-30天左右')):
|
||||
rates.append({'service':service,'kind':'parcel','origin':origin,'zone':zone,
|
||||
'unit':'kg','tax':'included','min_weight':'12','price':str(price+offset),
|
||||
'eta':eta,'destinations':[],'source_sheet':'合成时效','source_cell':'A1'})
|
||||
book['rates']=rates
|
||||
return book
|
||||
|
||||
|
||||
class QuoteTimingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'2'*64})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
import_snapshot(connection,timing_snapshot(),activate=True)
|
||||
connection.commit();connection.close()
|
||||
|
||||
def ask(self,question,cid=None):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
return events[0]['t']
|
||||
|
||||
def test_fastest_and_slowest_show_each_region_origin_and_the_correct_amount(self):
|
||||
text=self.ask('我想发一批货,一吨发发到美国走海运,最快的和最慢的价格分别是多少?')
|
||||
fast,slow=text.split('最慢参考方案')
|
||||
self.assertIn('最快参考方案',fast)
|
||||
self.assertIn('美西 8元/KG,1000KG = 8000元',fast)
|
||||
self.assertIn('美东 10元/KG,1000KG = 10000元',fast)
|
||||
self.assertIn('美森正班海派 · 华南',fast)
|
||||
self.assertNotIn('普船海派',fast)
|
||||
self.assertIn('美西 4元/KG,1000KG = 4000元',slow)
|
||||
self.assertIn('美东 6元/KG,1000KG = 6000元',slow)
|
||||
self.assertIn('船开后25-30天左右提取',slow)
|
||||
self.assertNotIn('合德海派',text)
|
||||
|
||||
def test_short_followups_keep_weight_origin_zone_and_selected_comparison(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('1000KG到美国走海运,最快和最慢价格分别多少',cid)
|
||||
for question in ('慢的呢?','义乌交仓','美东呢','请给我具体报价'):
|
||||
text=self.ask(question,cid)
|
||||
self.assertIn('美国 1000KG',text)
|
||||
self.assertIn('最慢参考方案',text)
|
||||
self.assertNotIn('最快参考方案',text)
|
||||
self.assertNotIn('华南',text)
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
text=self.ask('50KG呢',cid)
|
||||
self.assertIn('50KG = 300元',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
text=self.ask('全部渠道报价',cid)
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertIn('合德海派',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
text=self.ask('新的一批100KG发美国海运报价',cid)
|
||||
self.assertIn('美国 100KG',text)
|
||||
self.assertIn('华南',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
|
||||
def test_numeric_timing_groups_keep_destinations_tax_and_service_kinds_separate(self):
|
||||
base={'zone':'美西','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'eta':'10-35天','service':'wide'},
|
||||
{**base,'eta':'15-25天','service':'narrow'},
|
||||
{**base,'zone':'美东','eta':'40-45天','service':'east'},
|
||||
{**base,'tax':'excluded','eta':'20天','service':'excluded'},
|
||||
{**base,'kind':'truck','eta':'50天','service':'truck'},
|
||||
{**base,'eta':'未提供时效','service':'unknown'}]
|
||||
self.assertEqual({r['service'] for r in select_timed_rates(rates)},
|
||||
{'wide','east','excluded','truck'})
|
||||
self.assertEqual({r['service'] for r in select_timed_rates(rates,slowest=True)},
|
||||
{'wide','east','excluded','truck'})
|
||||
self.assertEqual(select_timed_rates([{**base,'eta':'若干天'}]),[])
|
||||
|
||||
def test_english_comparison_keeps_numbers_and_renders_both_speeds(self):
|
||||
text=self.ask('1000kg to USA by sea, fastest and slowest prices please')
|
||||
self.assertIn('Fastest reference options',text)
|
||||
self.assertIn('Slowest reference options',text)
|
||||
self.assertIn('1000KG = 4000 CNY',text)
|
||||
|
||||
def test_different_regional_times_are_rendered_on_their_own_price_lines(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET eta='船开后18天左右' WHERE service='美森正班海派' AND zone='美东'")
|
||||
connection.commit();connection.close()
|
||||
text=self.ask('美国1000KG海运最快价格,义乌')
|
||||
self.assertIn('美东 10元/KG,1000KG = 10000元(船开后18天左右提取)',text)
|
||||
self.assertIn('美西 8元/KG,1000KG = 8000元',text)
|
||||
|
||||
def test_unknown_times_do_not_become_a_fastest_or_slowest_promise(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET eta='若干天'")
|
||||
connection.commit();connection.close()
|
||||
text=self.ask('美国1000KG海运最快和最慢价格')
|
||||
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