Compare commits

..
27 changed files with 913 additions and 459 deletions
+2 -2
View File
@@ -9,14 +9,14 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `static/js/overview.js`: automatic scoped overview; `order_listing.py`: `read_overview` |
| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `order_listing.py`: `read_overview`, `answer_overview` for chat replies |
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees |
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_tracking.py`: `current_stage` |
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals and subset questions; `order_tracking.py`: `current_stage` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
Vendored
+3 -3
View File
@@ -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" .
'''
}
+18
View File
@@ -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 "$@"
+3 -1
View File
@@ -78,6 +78,8 @@ tests/ # 按业务模块组织的回归
## 模块边界与依赖方向
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
@@ -153,7 +155,7 @@ tests/ # 按业务模块组织的回归
`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。
登录成功或恢复有效登录时,`overview.js` 自动请求既有 `/api/customer/session?overview=1` 并展开可收起的订单概览。普通会话状态查询保持原响应契约,游客响应不含订单事实。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。错误状态保留重试入口,退出或换账号立即清空概览,迟到响应受请求版本和客户身份约束。概览查询不创建聊天消息,查看计数及入仓码复用现有查询入口。
用户询问“订单概览”“我的订单情况”时,`answer_overview` 在现有聊天流中回复,沿用订单事实的登录校验与会话保存。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。结果仅出现在聊天消息及对应历史中,登录和恢复登录只更新身份状态。
语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。
+2
View File
@@ -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`。
以下参数由私有灰度配置提供,实际地址和凭据值不写入源码:
| 参数 | 含义 |
+8
View File
@@ -3,7 +3,15 @@
import re
def is_acknowledgement(message):
text=re.sub(r'[\s,,。.!!??]','',message).lower()
return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|'
r'ok(?:ay)?|thanks?|thankyou|gotit|understood)+',text))
def answer_capability(message):
if is_acknowledgement(message):
return {'t':'好的,有需要随时告诉我。' if re.search(r'[\u4e00-\u9fff]',message) else 'You are welcome. Let me know if you need anything else.'}
if re.search(r'照片|图片',message) and re.search(r'报价|价格|判断|识别|尺寸',message):
return {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
if re.search(r'(?:手机号|手机号码|电话号码).*(?:查|订单|运单)|(?:查|查询).*手机号',message):
+2 -15
View File
@@ -3,7 +3,6 @@
import hashlib
import json
import secrets
import sqlite3
import time
from flask import g, has_app_context, jsonify, request
@@ -135,20 +134,8 @@ def bind_order_conversation(conv_id):
connection.close()
def session_status(include_overview=False):
result={'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
if include_overview and g.customer:
from .visitor_service import customer_tenant_ids
from .order_listing import read_overview
tenants=customer_tenant_ids()
if not tenants:
result['overview_error']='unlinked'
else:
try:
result['overview']=read_overview(tenants)
except sqlite3.Error:
result['overview_error']='unavailable'
return result
def session_status():
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
def order_login_response():
+27 -17
View File
@@ -335,8 +335,16 @@ def answer_query(message, conv_id):
tenants = customer_tenant_ids()
from .order_explanations import general_explanation, detail_question, explain_order
if not references:
from .order_listing import answer_overview
overview=answer_overview(message,tenants)
if overview is not None:return overview
explanation=general_explanation(message)
if explanation:return {'t':explanation}
from .order_followups import answer_followup
followup=answer_followup(message,tenants,conv_id)
if followup is not None:
if '_order_reference' not in followup:return followup
references=[followup['_order_reference']]
count_intent = parse_count_query(message) if not references else None
if count_intent is not None:
return answer_count_query(count_intent,tenants,conv_id)
@@ -351,27 +359,29 @@ def answer_query(message, conv_id):
reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message):
from .db import connect
from .customer_faq import is_acknowledgement
connection = connect()
try:
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC', (conv_id,))
for role, content in recent:
if role == 'user':
if is_acknowledgement(content):continue
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
finally:
connection.close()
for role, content in recent:
if role == 'user':
earlier_references = extract_reference(content)
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
if previous.get('kind') == 'order':
reference = previous['no']
break
except (ValueError, KeyError, TypeError):
pass
if not reference:
if mutation:
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
+100
View File
@@ -0,0 +1,100 @@
"""Resolve bounded list references and subset questions from scoped saved replies."""
import re
import sqlite3
from .db import connect
from .order_query import COUNT,NOUN,CONDITION,parse_query
ORDINALS={word:index for index,word in enumerate(
'一 二 三 四 五 六 七 八 九 十 十一 十二 十三 十四 十五 十六 十七 十八 十九 二十'.split(),1)}
RELATIVE=r'^(?:这其中|其中|这些(?:订单|运单)?(?:中|里)?|这几(?:票|条)(?:中|里)?|里面|当中)'
def is_relative_question(message):
return (bool(re.match(RELATIVE,message))
and bool(re.search(COUNT+'|'+NOUN+'|'+CONDITION+r'|轨迹|节点|阶段|到哪|在哪',message,re.I))
and not re.search(r'报价|海运|海派|海卡|空运|运费|费用|价格|KG|公斤|千克|CBM|立方',message,re.I))
def ordinal_request(message):
from .order_data import is_order_followup
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:请|帮我|看看|看一下|查询)?第(\d{1,5}|[一二三四五六七八九十]{1,3})'
r'(?:个|票|条|笔)(?:订单|运单)?(?:的)?(.*)',text)
if not match:return None
detail=match[2]
if detail not in ('','呢','详情') and not is_order_followup(detail):return None
number=int(match[1]) if match[1].isdigit() else ORDINALS.get(match[1],0)
return number
def shown_references(connection,conv_id,state):
if 'shown' in state:
return [(item['inbound_code'] or '未登记',item['order_no']) for item in state['shown']]
row=connection.execute("SELECT content FROM messages WHERE conv_id=? AND id=? AND role='assistant'",
(conv_id,state['message_id'])).fetchone()
# Older states stored only the reply ID. Accept its fixed, unambiguous row format.
lines=[line for line in (row[0] if row else '').splitlines() if line.startswith('- 入仓码:')]
result=[]
for line in lines:
match=re.match(r'^- 入仓码:([^\r\n;]+);订单号:([^\r\n;]+);(?:业务状态|异常原因):',line)
if not match or any(line.count(label)>1 for label in (';订单号:',';业务状态:',';异常原因:')):
return []
result.append(match.groups())
return result
def answer_followup(message,tenants,conv_id):
from .customer_service import order_login_response
from .order_listing import load_state,answer_summary
text=message.strip(' ,,。.!!??')
ordinal=ordinal_request(text)
relative=is_relative_question(text)
reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?'
r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text))
query_text=re.sub(RELATIVE,'',text)
query_text=re.sub(r'^(?:那么|那)','',query_text)
intent=parse_query(query_text+'订单有多少') if not (re.search(NOUN,query_text,re.I) or re.search(COUNT,query_text,re.I)) else parse_query(query_text)
if relative and intent is None and re.search(COUNT,query_text,re.I):intent=parse_query(query_text+'订单')
short_count=(not re.search(NOUN,text,re.I) and not re.search(COUNT,text,re.I)
and intent is not None and not intent['unsupported']
and any(intent.get(key) for key in ('status','stage','exception','grouped')))
if ordinal is None and not relative and not reason and not short_count:return None
if not tenants:return order_login_response()
try:
previous=load_state(conv_id)
if ordinal is not None:
if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'}
connection=connect()
try:
shown=shown_references(connection,conv_id,previous)
finally:connection.close()
if not shown:return {'t':'当前列表没有可查询的订单,请重新指定筛选条件,或直接提供入仓码。'}
if not 1<=ordinal<=len(shown):
return {'t':f'刚才的列表展示了{len(shown)}票,请选择其中一票,或回复“查看全部”展开列表后再查询。'}
inbound,number=shown[ordinal-1]
return {'_order_reference':number if inbound=='未登记' else inbound}
if short_count and not relative and (not previous or previous.get('selected')):return None
if reason:
if not previous or previous.get('selected'):return None
intent={**previous['query'],'exception':True,'unsupported':False}
elif relative:
if not previous:return {'t':'请先查询要筛选的订单范围,再继续问“其中”的数量。'}
if previous.get('selected'):
return {'t':'您想统计刚才列表中的订单,还是查询当前这一票?请说明订单范围或提供入仓码。'}
if intent is None or intent['unsupported'] or 'summaries' in intent:
return {'t':'请明确要在当前订单范围内筛选的一个条件,例如“其中异常的有多少票”。'}
for key in ('status','stage'):
if previous['query'][key] and intent.get(key) and previous['query'][key]!=intent[key]:
return {'t':'当前范围与这次条件对应不同状态或节点,请确认要切换筛选条件,还是继续查看原范围。'}
intent={**intent,**{key:intent.get(key) or value for key,value in previous['query'].items()}}
elif intent.get('grouped'):
if not previous or previous.get('selected'):return None
intent={**intent,**previous['query']}
if intent is None or intent['unsupported']:return None
return answer_summary(intent,tenants,conv_id,reasons=reason)
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
+102 -27
View File
@@ -30,6 +30,9 @@ def valid_filters(query):
def load_state(conv_id):
from .customer_service import current_customer_id,current_scope_key
from .visitor_service import visitor_id
from .customer_faq import is_acknowledgement
from .order_followups import ordinal_request,shown_references,is_relative_question
from .order_data import is_order_followup
if not conv_id:return None
connection=connect()
try:
@@ -40,8 +43,23 @@ def load_state(conv_id):
if not isinstance(state,dict) or not valid_filters(state.get('query')):return None
if type(state.get('page')) is not int or not 1<=state['page']<=10000:return None
if state.get('size') not in (5,20) or type(state.get('message_id')) is not int:return None
if 'shown' in state:
shown=state['shown']
if not isinstance(shown,list) or len(shown)>20:return None
if any(not isinstance(item,dict) or set(item)!={'inbound_code','order_no'}
or not isinstance(item['order_no'],str) or not item['order_no']
or item['inbound_code'] is not None and not isinstance(item['inbound_code'],str) for item in shown):
return None
state['selected']=False
for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id",
(conv_id,state['message_id'])):
if is_acknowledgement(text):continue
if is_relative_question(text):continue
ordinal=ordinal_request(text)
if ordinal is not None:
state['selected']=1<=ordinal<=len(shown_references(connection,conv_id,state))
continue
if state['selected'] and is_order_followup(text):continue
if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'):
return None
return state
@@ -112,31 +130,66 @@ def read_overview(tenants):
connection.close()
def read_orders(tenants,query,page=1,size=5):
def answer_overview(message,tenants):
question=re.sub(r'[\s,,。.!!??]','',message)
chinese=re.fullmatch(r'(?:请|帮我|请帮我)?(?:看一下|看看|查看|查询|显示)?(?:一下)?'
r'(?:(?:我|当前账号)的?|当前|现在)?(?:订单|运单)(?:概览|概况|总体情况|整体情况|情况|汇总)(?:呢|吧|吗)?',question)
english=re.fullmatch(r'(?:please\s+)?(?:(?:show|give)\s+(?:me\s+)?)?(?:my\s+)?(?:order|shipment)s?\s+(?:overview|summary)',message.strip(' .!?'),re.I)
if not chinese and not english:return None
if not tenants:
from .customer_service import order_login_response
return order_login_response()
try:
data=read_overview(tenants)
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
lines=[f'您当前共有 {data["total"]} 票订单,履约中 {data["processing"]} 票,异常订单 {data["exceptions"]} 票。']
if data['stages']:
lines.append('履约中订单的当前实际节点:')
lines.extend(f'- {STAGE_LABELS[stage["code"]]}:{stage["count"]}票' for stage in data['stages'])
if data['orders']:
lines.append('重点订单(异常优先,最多5票):')
lines.extend(row_text(order) for order in data['orders'])
return {'t':'\n'.join(lines),'_customer_data':True}
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
@@ -145,34 +198,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':'订单信息暂时无法读取,请稍后重试。'}
@@ -198,7 +272,8 @@ def answer_list(message,tenants,conv_id=None):
if intent is None or intent['unsupported']:return unsupported(True)
query=filters(intent);page=1;size=20
data=read_orders(tenants,query,page,size);page=data['page'];total=data['total']
state={'query':query,'page':page,'size':size}
state={'query':query,'page':page,'size':size,
'shown':[{'inbound_code':row['inbound_code'],'order_no':row['order_no']} for row in data['rows']]}
if not total:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True,'_order_list_state':state}
label=STATUS_LABELS[query['status']] if query['status'] else '全部'
first=f'先列出{size}票;' if page==1 and total>size else ''
+16 -4
View File
@@ -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,
+7 -1
View File
@@ -94,7 +94,13 @@ def answer_business(intent,book):
return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。'
if mode=='fees':
lines=[]
if re.search(r'含税|含派送|全包|包不包',text):
if re.search(r'含税|包税|税费',text):
from .quote_engine import variants,match_rates
taxes={rate['tax'] for destination in variants(intent,book) for rate in match_rates(intent,book,destination)}
if taxes=={'included'}:lines.append('当前匹配方案标注包税,按该方案的税费口径核算。')
elif taxes=={'excluded'}:lines.append('当前匹配方案标注不包税,税费需单独核对。')
else:lines.append('当前存在不同税费口径,请指定包税或不包税方案继续核对。')
if re.search(r'含派送|全包|包不包',text):
lines.append('报价行标注“包税”或“不包税”的方案按各自税费方式计算;海派的派送还需核对目的仓/邮编,住宅、商业地址及偏远等附加费不能直接当作已全部包含。')
metal=material_fee(book)
if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text):
+19 -4
View File
@@ -3,13 +3,28 @@
from copy import deepcopy
from decimal import Decimal
from .quote_intent import kg,MISSING_WEIGHT_UNIT
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
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','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'}
unit=current.get('weight_unit')
pending=previous.get('pending_weight')
weight_change=current['weight'] is not None or bool(unit and pending is not None)
if unit:
if pending is None:result['errors'].append('请同时提供重量数值和单位,例如800KG。')
else:result['weight']=str(kg(pending,unit))
elif pending is not None and current['weight'] is None and current.get('pending_weight') is None:
result['pending_weight']=pending
result['errors'].append(MISSING_WEIGHT_UNIT)
if not current.get('selection') and not current.get('clear_selection'):
result['selection']=previous.get('selection')
if current.get('clear_selection'):
excluded.update(('services','kinds','transport','air_only','air_requested'))
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
result['metal_chargeable_weight']=None
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
@@ -26,12 +41,12 @@ def merge_intent(current,previous):
if current.get('allocations'):excluded.update(('packages','boxes','dims','cbm'))
if current['transport'] or current['kinds'] or current['services']:excluded.update(('air_only','air_requested'))
if current['air_only'] or current['tax']=='excluded':excluded.add('kinds')
if current['weight'] is not None:
if weight_change:
if not current.get('packages'):excluded.add('packages')
if not current.get('allocations'):excluded.add('allocations')
if current['dims']:excluded.add('cbm')
if current['cbm'] is not None:excluded.add('dims')
if any(current.get(k) is not None for k in ('weight','dims','cbm','boxes','packages','allocations','transport','kinds','services','tax')) and current.get('metal_chargeable_weight') is None:
if (weight_change or any(current.get(k) is not None for k in ('dims','cbm','boxes','packages','allocations','transport','kinds','services','tax'))) and current.get('metal_chargeable_weight') is None:
excluded.add('metal_chargeable_weight')
if current.get('material')=='ordinary':excluded.update(('metal_scope','metal_chargeable_weight'))
if current.get('metal_scope')=='all' and current.get('metal_chargeable_weight') is None:
@@ -45,7 +60,7 @@ def merge_intent(current,previous):
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
elif current['cbm'] is not None:
for piece in pieces:piece.update(dims=None,dim_unit='cm')
if previous.get('quantity_uncertain') and current['weight'] is None and not current.get('preserve_total'):
if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'):
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
+52 -28
View File
@@ -127,11 +127,30 @@ def quantity_number(value,intent):
return '约'+number(value,6)
def select_timed_rates(rates,slowest=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'])
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 +173,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)))
if rate_selection in ('slowest','speed_compare'):
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True)))
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,9 +230,6 @@ def render_rates(intent, book, destinations, remote_fees):
elif intent.get('cbm') and rate['unit']=='cbm':
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
parts.append(rate_text)
eta = re.sub(r'\s*无时效赔偿', '', 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 fees:
@@ -216,7 +240,7 @@ def render_rates(intent, book, destinations, remote_fees):
if not any(intent.get(key) for key in ('warehouse','postal')):
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
if intent.get('mode')=='time':
pass
lines.append('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。')
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
lines.append('货物多少公斤、几箱?')
elif not intent['boxes'] or not intent['dims']:
+34 -6
View File
@@ -9,10 +9,16 @@ US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))',re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|收费|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*(kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅)(?![A-Za-z])',re.I)
WEIGHT_UNITS = r'kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅'
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*('+WEIGHT_UNITS+r')(?![A-Za-z])',re.I)
MISSING_WEIGHT_UNIT = '请补充修改后重量的单位,例如KG或吨,我再按新重量核算。'
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
def resumes_quote(message):
return bool(re.search(r'(?:回到|回头|继续)(?:刚才|之前|前面|上面)(?:的)?(?:美国)?(?:海运)?(?:报价|价格|运费)',message))
def chinese_number(text):
digits={c:i for i,c in enumerate('零一二三四五六七八九')};digits.update({'两':2,'〇':0})
if all(c in digits for c in text):return ''.join(str(digits[c]) for c in text)
@@ -45,6 +51,16 @@ def kg(value,unit):
return value
def request_selection(text):
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
fastest=bool(re.search(r'最快|快的|fastest',text,re.I))
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
if fastest and slowest:return 'speed_compare'
if fastest:return 'fastest'
if slowest:return 'slowest'
return None
def request_mode(text):
# Business topics outrank generic verbs such as “怎么算”.
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
@@ -58,17 +74,15 @@ def request_mode(text):
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
from .shipping_faq import preparation_topic
if preparation_topic(text):return 'preparation'
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text):return 'total'
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
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):
@@ -86,6 +100,10 @@ def parse_intent(message,warehouse_codes):
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
and not t.startswith(('USD','RMB','CNY'))]
errors=materials['errors'][:]
pending_weight=re.search(r'(?:总重量|总重|重量|毛重|实重)\s*(?:改成|改为|改到|换成|调整为|是|为)?\s*'
r'(-?\d+(?:\.\d+)?)(?![\d.])(?!\s*(?:'+WEIGHT_UNITS+r')(?![A-Za-z]))',text,re.I)
if pending_weight:errors.append(MISSING_WEIGHT_UNIT)
weight_unit=re.fullmatch(r'(?:单位(?:是|用|为)?|按)?\s*('+WEIGHT_UNITS+r')(?:计算|算|计)?[。.!!??\s]*',text,re.I)
from .quote_allocations import allocations
assigned=allocations(quantity_text,warehouses,WEIGHT,kg)
weights=[m for m in WEIGHT.finditer(quantity_text) if not quantity_text[:m.start()].rstrip().endswith(('x','X','×','*'))]
@@ -154,6 +172,7 @@ def parse_intent(message,warehouse_codes):
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
mode=request_mode(text)
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'allocations':assigned,'origin':origin,
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
@@ -161,7 +180,11 @@ def parse_intent(message,warehouse_codes):
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
'errors':errors,'mode':mode,'selection':request_selection(text),
'pending_weight':pending_weight[1] if pending_weight else None,
'weight_unit':weight_unit[1] if weight_unit else None,
'clear_selection':bool(re.search(r'(?:全部|所有|各个?)(?:的)?(?:渠道|线路|方案)|(?:全部|所有)(?:报价|价格|运价)',text)),
'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
'material':materials['material'],'metal_scope':materials['metal_scope'],
'metal_chargeable_weight':materials['metal_chargeable_weight'],
'metal_weight_update':materials['metal_weight_update'],
@@ -170,6 +193,8 @@ def parse_intent(message,warehouse_codes):
def is_followup(message,intent):
if resumes_quote(message):return True
if intent.get('weight_unit'):return True
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
if TOPIC_BOUNDARY.search(message):return False
text=message.strip().strip(',,。.!!?? ')
@@ -178,6 +203,9 @@ def is_followup(message,intent):
if intent.get('warehouse') or intent.get('unknown_warehouses'):
return not (intent['country'] and intent['weight'])
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
if re.fullmatch(r'具体(?:多少|多少钱)(?:呢)?',text):return True
if re.fullmatch(r'(?:请|请问|帮我|给我|请给我|麻烦)?(?:再|继续)?(?:提供|显示|列出|看一下|看看|查询)?'
r'(?:具体|详细|全部渠道|所有渠道|各渠道)?(?:报价|价格|运费|费用)(?:呢|是多少|多少|怎么样|如何|多少钱)?',text):return True
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
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')))
+4
View File
@@ -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 '),
+28 -5
View File
@@ -7,21 +7,40 @@ import sqlite3
from .db import connect
from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
from .quote_intent import resumes_quote
from .customer_faq import is_acknowledgement
def previous_quote_intents(conv_id,codes):
def previous_quote_intents(conv_id,codes,resume=False):
"""Read the current quote back to its topic boundary, without a turn cutoff."""
earlier_intents=[]
resume_updates=[]
if conv_id:
connection=connect()
try:
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,))
for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous):break
if is_acknowledgement(previous):continue
earlier=parse_intent(previous,codes)
if resumes_quote(previous):
earlier_intents.append(earlier)
# Retain edits made after an explicit return while crossing other topics.
resume_updates=earlier_intents[:]
resume=True
continue
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous))
quote_root=bool((earlier['country'] or earlier['warehouse'] or earlier.get('unknown_warehouses'))
and QUOTE_WORDS.search(previous))
if quote_root and not re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):
boundary=False
boundary=boundary or bool(re.search(r'订单|运单|入仓码',previous) and not re.search(r'报价|海运|海派|海卡|空运',previous))
candidate=(QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal')))
if boundary or not candidate:
if not resume:break
earlier_intents=resume_updates[:]
continue
if earlier['country']=='other':break
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
earlier_intents.append(earlier)
if not is_followup(previous,earlier):break
finally:
@@ -35,7 +54,7 @@ def intent_with_context(message, conv_id, book):
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
earlier_intents=previous_quote_intents(conv_id,codes) if is_followup(message,intent) else []
earlier_intents=previous_quote_intents(conv_id,codes,resume=resumes_quote(message)) if is_followup(message,intent) else []
if intent.get('metal_weight_update') and not earlier_intents:
intent['metal_chargeable_weight']=None
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
@@ -64,6 +83,10 @@ def answer_quote(message, conv_id):
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if resumes_quote(message):
return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'}
if intent.get('weight_unit'):
return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'}
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None
+1 -1
View File
@@ -9,7 +9,7 @@ bp = Blueprint('customer', __name__)
@bp.get('/api/customer/session')
def session():
return customer_service.session_status(include_overview=request.args.get('overview')=='1')
return customer_service.session_status()
@bp.post('/api/customer/login')
-79
View File
@@ -305,82 +305,3 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.msg-action-btn { min-height: 40px; }
.to-bottom { right: 15px; }
}
.customer-overview {
flex-shrink: 0;
max-height: 36%;
margin: 12px 32px 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.customer-overview > summary {
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 1;
min-height: 44px;
padding: 10px 16px;
background: var(--surface);
cursor: pointer;
list-style: none;
}
.customer-overview > summary::-webkit-details-marker { display: none; }
.customer-overview > summary::after { content: '⌄'; color: var(--muted); }
.customer-overview[open] > summary::after { transform: rotate(180deg); }
#customer-overview-title { flex-shrink: 0; font-weight: 650; }
#customer-overview-summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-size: 12px;
color: var(--secondary);
}
.customer-overview-body { padding: 0 16px 12px; }
.overview-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.overview-metric {
display: flex;
flex-direction: column;
min-width: 0;
padding: 8px;
border: 0;
border-radius: 8px;
background: var(--surface-soft);
color: var(--text);
text-align: left;
}
.overview-metric strong { font-size: 21px; line-height: 1.3; }
.overview-metric span { color: var(--secondary); font-size: 12px; }
.overview-metric:last-child strong { color: var(--danger); }
.overview-stages { display: flex; flex-wrap: wrap; gap: 6px 12px; margin: 10px 0; color: var(--secondary); font-size: 12px; }
.overview-orders { display: grid; gap: 6px; }
.overview-order {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 10px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
text-align: left;
}
.overview-reference { overflow-wrap: anywhere; font-size: 13px; }
.overview-stage { max-width: 180px; color: var(--secondary); font-size: 12px; text-align: right; }
.overview-reason { grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--danger); font-size: 12px; }
.overview-order.has-exception { border-left: 3px solid var(--danger); }
.overview-refresh { margin-top: 8px; padding: 4px 0; border: 0; background: transparent; color: var(--brand-text); font-size: 12px; }
.overview-empty { color: var(--secondary); font-size: 13px; }
@media (max-width: 720px) {
.customer-overview { max-height: 32%; margin: 8px 12px 0; }
.customer-overview > summary { padding: 9px 12px; gap: 8px; }
.customer-overview-body { padding: 0 12px 10px; }
.overview-stage { max-width: 120px; }
}
-62
View File
@@ -43,7 +43,6 @@ let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let customerOverview = null;
let orderLookup = null;
let lookupRequestVersion = 0;
let customerRecovery = null;
@@ -314,33 +313,6 @@ const I18N = {
footerQuote: "美国报价",
footerException: "异常订单",
exceptionQuery: "我有多少个异常订单?",
overviewTitle: "订单概览",
overviewTotal: "全部订单",
overviewProcessing: "履约中",
overviewExceptions: "异常订单",
overviewLoading: "正在查询订单…",
overviewUnlinked: "当前账号尚未关联订单",
overviewFailed: "订单概览暂时无法加载",
overviewRefresh: "刷新概览",
overviewEmpty: "当前没有履约中或已标记异常的订单。",
overviewAllQuery: "列出我的全部订单",
overviewProcessingQuery: "列出履约中的订单",
overviewStageLabels: {
DOMESTIC_RECEIVED: "已入国内仓",
LOADING_COMPLETED: "已完成装柜",
DEPARTED_WAREHOUSE: "已离开国内仓库",
AT_ORIGIN_PORT: "已进入起运港",
LOADED_ON_VESSEL: "已装船",
DEPARTED_ORIGIN_PORT: "已开船",
IN_TRANSIT: "海运途中",
ARRIVED_DESTINATION_PORT: "已到目的港",
DISCHARGED_FROM_VESSEL: "已卸船、未清关",
PICKUP_READY: "已清关、可提柜",
PICKED_UP: "已提柜",
EMPTY_RETURNED: "已还柜",
NO_ACTUAL: "暂无实际节点",
MIXED: "多柜进度不同"
},
footerHuman: "联系人工",
inputShort: "输入入仓码…",
uploading: "附件正在上传,请稍候…",
@@ -458,33 +430,6 @@ const I18N = {
footerQuote: "US quote",
footerException: "Exception orders",
exceptionQuery: "How many exception orders do I have?",
overviewTitle: "Order overview",
overviewTotal: "All orders",
overviewProcessing: "In progress",
overviewExceptions: "Exceptions",
overviewLoading: "Loading orders…",
overviewUnlinked: "No orders are linked to this account",
overviewFailed: "Order overview could not load",
overviewRefresh: "Refresh overview",
overviewEmpty: "No orders are in progress or flagged with an exception.",
overviewAllQuery: "List all my orders",
overviewProcessingQuery: "List my processing orders",
overviewStageLabels: {
DOMESTIC_RECEIVED: "Received at origin warehouse",
LOADING_COMPLETED: "Container loaded",
DEPARTED_WAREHOUSE: "Left origin warehouse",
AT_ORIGIN_PORT: "At origin port",
LOADED_ON_VESSEL: "Loaded on vessel",
DEPARTED_ORIGIN_PORT: "Vessel departed",
IN_TRANSIT: "In transit",
ARRIVED_DESTINATION_PORT: "At destination port",
DISCHARGED_FROM_VESSEL: "Discharged, awaiting clearance",
PICKUP_READY: "Cleared, ready for pickup",
PICKED_UP: "Container picked up",
EMPTY_RETURNED: "Empty container returned",
NO_ACTUAL: "No actual tracking events",
MIXED: "Containers at different stages"
},
footerHuman: "Human support",
inputShort: "Inbound code…",
uploading: "Uploading your attachment…",
@@ -581,7 +526,6 @@ function applyLanguage(lang){
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
if(voiceInput) voiceInput.refresh();
if(customerOverview) customerOverview.render();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
@@ -2129,15 +2073,9 @@ voiceInput = ChatVoice.create({
if(!await doSend()) voiceInput.setMode(false);
}
});
customerOverview = CustomerOverview.create({fetch:fetch,text:t,
onQuery:function(question){window.askNewTopic(question);},
onLookup:function(order,button){showOrderLookup(order.inbound_code || order.order_no,order.inbound_code ? 'inbound' : 'order',button);},
onExpired:function(){if(customerLogin) customerLogin.refresh();}
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); },
onChange:function(user,previous){
customerOverview.setUser(user);
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
const from = conversationVersion;
-130
View File
@@ -1,130 +0,0 @@
// Display a fresh, customer-scoped order overview after login or session recovery.
var CustomerOverview = {
create(options){
const panel=document.getElementById('customer-overview');
const title=document.getElementById('customer-overview-title');
const summary=document.getElementById('customer-overview-summary');
const body=document.getElementById('customer-overview-body');
let user=null;
let overview=null;
let failure='';
let loading=false;
let requestVersion=0;
let controller=null;
panel.hidden=true;
function element(tag,className,text){
const node=document.createElement(tag);
if(className) node.className=className;
if(text !== undefined) node.textContent=text;
return node;
}
function queryButton(label,question,className){
const button=element('button',className,label);
button.type='button';
button.onclick=function(){
panel.open=false;
options.onQuery(question);
};
return button;
}
function render(){
title.textContent=options.text('overviewTitle');
body.innerHTML='';
panel.hidden=!user;
if(!user){summary.textContent='';return;}
if(!overview){
const key=loading ? 'overviewLoading' : failure==='unlinked' ? 'overviewUnlinked' : 'overviewFailed';
summary.textContent=options.text(key);
}else{
summary.textContent=options.text('overviewProcessing')+' '+overview.processing+' · '+options.text('overviewExceptions')+' '+overview.exceptions;
const metrics=element('div','overview-metrics');
for(const [key,count,question] of [
['overviewTotal',overview.total,options.text('overviewAllQuery')],
['overviewProcessing',overview.processing,options.text('overviewProcessingQuery')],
['overviewExceptions',overview.exceptions,options.text('exceptionQuery')]
]){
const button=queryButton('',question,'overview-metric');
button.append(element('strong','',String(count)),element('span','',options.text(key)));
metrics.appendChild(button);
}
body.appendChild(metrics);
const stages=element('div','overview-stages');
const labels=options.text('overviewStageLabels');
for(const stage of overview.stages){
stages.appendChild(element('span','',labels[stage.code]+' '+stage.count));
}
body.appendChild(stages);
const orders=element('div','overview-orders');
for(const order of overview.orders){
const button=element('button','overview-order');
button.type='button';
const reference=order.inbound_code || order.order_no;
button.appendChild(element('strong','overview-reference',reference));
button.appendChild(element('span','overview-stage',labels[order.stage]));
if(order.exception_reason){
button.classList.add('has-exception');
button.appendChild(element('span','overview-reason',order.exception_reason));
}
button.onclick=function(){options.onLookup(order,button);};
orders.appendChild(button);
}
if(!overview.orders.length) orders.appendChild(element('p','overview-empty',options.text('overviewEmpty')));
body.appendChild(orders);
}
const refresh=element('button','overview-refresh',options.text(loading ? 'overviewLoading' : 'overviewRefresh'));
refresh.type='button';
refresh.disabled=loading;
refresh.onclick=refreshOverview;
body.appendChild(refresh);
}
async function refreshOverview(){
if(!user || loading) return;
const token=++requestVersion;
const customerId=user.id;
controller=new AbortController();
const activeController=controller;
const timer=setTimeout(()=>activeController.abort(),10000);
loading=true;
failure='';
render();
try{
const response=await options.fetch('/api/customer/session?overview=1',{signal:activeController.signal});
const result=await response.json();
if(token!==requestVersion || !user || user.id!==customerId) return;
if(!response.ok || !result.ok) throw new Error('Overview unavailable');
if(!result.authenticated || !result.user || result.user.id!==customerId){
setUser(null);
if(options.onExpired) options.onExpired();
return;
}
overview=result.overview || null;
failure=result.overview_error || (overview ? '' : 'unavailable');
}catch(error){
if(token===requestVersion){overview=null;failure='unavailable';}
}finally{
clearTimeout(timer);
if(token===requestVersion){controller=null;loading=false;render();}
}
}
function setUser(next){
if(user && next && user.id===next.id) return;
requestVersion+=1;
if(controller) controller.abort();
controller=null;
user=next;
overview=null;
failure='';
loading=false;
panel.open=Boolean(user);
render();
if(user) refreshOverview();
}
return {setUser:setUser,render:render};
}
};
-5
View File
@@ -38,10 +38,6 @@
<button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button>
</div>
</header>
<details id="customer-overview" class="customer-overview" hidden>
<summary><span id="customer-overview-title">订单概览</span><span id="customer-overview-summary"></span></summary>
<div id="customer-overview-body" class="customer-overview-body"></div>
</details>
<div id="chatDom" role="log" aria-live="polite" aria-label="聊天内容">
<section class="ai-welcome-card">
<div class="ai-welcome-top">
@@ -104,7 +100,6 @@
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/overview.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
+61
View File
@@ -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, '')
}
})
+9 -47
View File
@@ -116,7 +116,6 @@ async function customerPage(pageOptions = {}){
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(customerState),user:customerState}
: url === '/api/customer/session?overview=1' ? {ok:true,authenticated:Boolean(customerState),user:customerState,overview:pageOptions.overview || {total:0,processing:0,exceptions:0,stages:[],orders:[]}}
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
: url.startsWith('/api/visitor/human/poll') ? state
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
@@ -124,7 +123,7 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.js', 'overview.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
if(file === 'voice.js'){
const createVoice=context.ChatVoice.create;
@@ -221,53 +220,16 @@ test('the homepage and exception shortcut match the US service in both languages
}
});
test('authenticated entry loads one overview with actionable counts and safe exception text', async () => {
const overview={total:9,processing:3,exceptions:2,stages:[{code:'IN_TRANSIT',count:3}],orders:[
{order_no:'ORDER20260001',inbound_code:'INB20260001',stage:'IN_TRANSIT',exception_reason:'<img src=x onerror=bad()>'}
]};
const app=await customerPage({human:false,customer:{id:1,name:'admin'},overview});
const panel=app.element('customer-overview');
assert.equal(panel.hidden,false);assert.equal(panel.open,true);
assert.match(app.element('customer-overview-summary').textContent,/履约中 3 · 异常订单 2/);
assert.equal(app.calls.filter(call=>call.url==='/api/customer/session?overview=1').length,1);
const body=app.element('customer-overview-body');
assert.equal(body.querySelector('.overview-reason').innerHTML,'');
assert.equal(body.querySelector('.overview-reason').textContent,'<img src=x onerror=bad()>');
await app.click(body.querySelector('.overview-order'));
assert.equal(app.element('order-lookup').hidden,false);
assert.equal(app.element('order-reference').value,'INB20260001');
await app.click(app.element('order-lookup-cancel'));
await app.click(body.querySelectorAll('.overview-metric')[1]);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'列出履约中的订单');
assert.equal(panel.open,false);
});
test('guest entry skips private overview and a late refresh cannot restore it after logout', async () => {
const guest=await customerPage({human:false});
assert.equal(guest.element('customer-overview').hidden,true);
assert.equal(guest.calls.filter(call=>call.url==='/api/customer/session?overview=1').length,0);
test('login keeps the chat layout and an overview is requested only by a chat question', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
let resolve;
app.hooks['/api/customer/session?overview=1']=()=>new Promise(done=>{resolve=done;});
app.element('customer-overview-body').querySelector('.overview-refresh').click();
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
await app.click(app.element('customer-account'));
resolve(response({ok:true,authenticated:true,user:{id:1,name:'admin'},overview:{total:99,processing:99,exceptions:99,stages:[],orders:[]}}));
assert.equal(app.calls.filter(call=>call.url.includes('overview=1') || call.url==='/stream_chat').length,0);
const template=fs.readFileSync(path.join(__dirname,'../../templates/index.html'),'utf8');
assert.doesNotMatch(template,/customer-overview|js\/overview\.js/);
await app.context.window.fillInput('我的订单概览');
await flush();
assert.equal(app.element('customer-overview').hidden,true);
assert.equal(app.element('customer-overview-body').textContent,'');
assert.doesNotMatch(app.element('customer-overview-summary').textContent,/99/);
});
test('overview failure can retry and account data stays out of its error state', async () => {
const app=await customerPage({human:false,customer:{id:1,name:'admin'}});
app.hooks['/api/customer/session?overview=1']=()=>response({ok:false},503);
await app.element('customer-overview-body').querySelector('.overview-refresh').click();
assert.match(app.element('customer-overview-summary').textContent,/暂时无法加载/);
assert.equal(app.element('customer-overview-body').querySelector('.overview-metric'),null);
delete app.hooks['/api/customer/session?overview=1'];
await app.element('customer-overview-body').querySelector('.overview-refresh').click();
assert.match(app.element('customer-overview-summary').textContent,/履约中 0/);
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'我的订单概览');
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
});
test('voice errors use a floating notice without changing the composer status', async () => {
+177 -21
View File
@@ -1,6 +1,7 @@
"""Statistics use explicit exception reasons and actual, order-level progress."""
import json
import re
from pathlib import Path
import sqlite3
import tempfile
@@ -48,6 +49,7 @@ class OrderStatisticsTests(unittest.TestCase):
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
self.assertNotIn('_order_list_state',str(events));self.assertNotIn('_customer_data',str(events))
self.assertNotIn('_order_reference',str(events))
self.assertNotIn('SECRET',str(events));self.assertNotIn('不能泄露',str(events))
return events[0]
@@ -57,25 +59,28 @@ class OrderStatisticsTests(unittest.TestCase):
self.assertTrue(self.ask(q)['login_required'])
read.assert_not_called()
def test_session_overview_is_opt_in_scoped_prioritized_and_not_cached(self):
def test_overview_is_requested_in_chat_and_saved_in_customer_protected_history(self):
with patch('logistics_chat.order_listing.read_overview') as read:
guest=self.client.get('/api/customer/session?overview=1&tenant_id=b',headers={'X-Tenant-Id':'b'})
self.assertFalse(guest.json['authenticated'])
self.assertNotIn('overview',guest.json)
for question in ('我的订单概览','查看订单情况','Order overview'):
self.assertTrue(self.ask(question)['login_required'])
read.assert_not_called()
self.login()
self.assertNotIn('overview',self.client.get('/api/customer/session').json)
response=self.client.get('/api/customer/session?overview=1&tenant_id=b',headers={'X-Tenant-Id':'b'})
overview=response.json['overview']
with patch('logistics_chat.order_listing.read_overview') as read:
self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json)
read.assert_not_called()
cid=self.client.post('/api/new_chat').json['id']
text=self.ask('我的订单概览',cid)['t']
self.assertIn('共有 16 票订单,履约中 12 票,异常订单 7 票',text)
self.assertIn('船舶已到目的港:10票',text)
self.assertEqual(text.count('- 入仓码:'),5)
self.assertEqual(text.count('异常原因:'),5)
response=self.client.get('/api/get_messages',query_string={'conv_id':cid})
self.assertIn('no-store',response.headers['Cache-Control'])
self.assertEqual((overview['total'],overview['processing'],overview['exceptions']),(16,12,7))
self.assertEqual(sum(stage['count'] for stage in overview['stages']),12)
self.assertEqual(len(overview['orders']),5)
self.assertTrue(all(order['exception_reason'] for order in overview['orders']))
self.assertNotIn('SECRET',response.get_data(as_text=True))
self.assertNotIn('tenant_id',response.get_data(as_text=True))
self.assertTrue(any(row['role']=='assistant' and row['content']==text for row in response.json))
self.client.post('/api/customer/logout')
self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json)
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login()
self.assertTrue(any(row['content']==text for row in self.client.get('/api/get_messages',query_string={'conv_id':cid}).json))
def test_overview_keeps_old_exception_ahead_of_new_processing_orders_and_refreshes(self):
self.login()
@@ -83,23 +88,24 @@ class OrderStatisticsTests(unittest.TestCase):
c=db.connect()
c.execute("UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id='a' AND order_no<>'STAT20260000'")
c.commit();c.close()
overview=self.client.get('/api/customer/session?overview=1').json['overview']
from logistics_chat.order_listing import read_overview
with self.app.app_context():overview=read_overview(('a',))
self.assertEqual(overview['orders'][0]['order_no'],'STAT20260000')
self.assertEqual(len({order['order_no'] for order in overview['orders']}),5)
with self.app.app_context():
c=db.connect()
order_data.import_records(c,[{'tenant_id':'a','order_no':'NEW20260001','status':'PROCESSING','exception_reason':'资料待核实'}])
c.commit();c.close()
refreshed=self.client.get('/api/customer/session?overview=1').json['overview']
self.assertEqual((refreshed['total'],refreshed['processing'],refreshed['exceptions']),(17,13,2))
refreshed=self.ask('订单概览')['t']
self.assertIn('共有 17 票订单,履约中 13 票,异常订单 2 票',refreshed)
def test_overview_read_failure_keeps_login_available_and_no_facts_leak(self):
self.login()
with patch('logistics_chat.order_listing.read_overview',side_effect=sqlite3.OperationalError('private details')):
result=self.client.get('/api/customer/session?overview=1').json
self.assertTrue(result['authenticated'])
self.assertEqual(result['overview_error'],'unavailable')
self.assertNotIn('overview',result)
result=self.ask('订单概览')
self.assertTrue(self.client.get('/api/customer/session').json['authenticated'])
self.assertIn('暂时无法读取',result['t'])
self.assertNotIn('共有',result['t'])
self.assertNotIn('private details',str(result))
def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self):
@@ -117,6 +123,156 @@ 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_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']
+1 -1
View File
@@ -114,7 +114,7 @@ class QuoteBusinessTests(unittest.TestCase):
text=self.ask('海运和空运哪个好,美国30KG')
self.assertIn('没有美国空运费率',text)
text=self.ask('美国美西海派多久')
self.assertIn('提取不等于送达',text);self.assertNotIn('元/KG',text)
self.assertIn('按开船后到提取计算',text);self.assertNotIn('元/KG',text)
text=self.ask('Please quote 30kg to US by sea')
self.assertIn('CNY/kg',text)
self.assertFalse(__import__('re').search(r'[\u4e00-\u9fff]',text),text)
+237
View File
@@ -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)