Compare commits

..
52 changed files with 4099 additions and 329 deletions
+2
View File
@@ -3,6 +3,8 @@
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
DEEPSEEK_API_KEY=
# Chat and supporting APIs each have a separate per-IP budget. Use a positive integer.
RATE_LIMIT_PER_MIN=600
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID=
# Default front-end customer account: admin / 123456. It grants no staff access.
+6 -3
View File
@@ -9,12 +9,15 @@ 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 | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
| 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 |
| Reply presentation | `static/js/reply.js`: `ChatReply.create`; `chat.js`: `doSend`, network completion and display queue cancellation; `tests/frontend/reply.test.cjs` |
| 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 |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`, `quote_question_text`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`, `select_cheapest_rates`; `quote_answers.py`: `timing_liability`; `quote_fees.py`: local business terms and fees |
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions 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` |
@@ -58,7 +61,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, `drafts.test.cjs`, and `reply.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
## Commit & Pull Request Guidelines
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" .
'''
}
+2
View File
@@ -118,6 +118,8 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
### 配置说明
请求限流默认每 IP 每分钟 600 次,聊天与辅助 API 分别计数。可在根目录 `.env` 中设置 `RATE_LIMIT_PER_MIN=600`,或通过进程、容器环境变量覆盖,取值须为正整数。配置在服务启动时读取,修改后重启服务。首页 GET/HEAD 独立于业务限流额度。
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
在根目录 `.env` 配置 `ASR_PROVIDER=baidu`、`BAIDU_ASR_API_KEY` 和 `BAIDU_ASR_SECRET_KEY`,使用已开通短语音识别的百度语音应用凭据,不能混用 OCR 或 DeepSeek 凭据。服务端须安装 FFmpeg 并加入 PATH,也可用 `FFMPEG_BIN` 指定可执行文件绝对路径;Docker 应用镜像已包含 FFmpeg。浏览器 WebM/MP4 录音通过内存管道转为 16 kHz 单声道 PCM,录音不写入上传目录或历史,只发送识别文字。修改后重启服务。可选的 `ASR_PROVIDER=dashscope` 使用 `.env.example` 中独立的 ASR 配置。
+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 "$@"
+30 -1
View File
@@ -31,6 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 回复展示 | `static/js/reply.js`:`ChatReply.create`;`chat.js`:`doSend` | 网络完成与文字展示分别结束;收到结束帧后继续显示已接收文字,停止、切换和错误会清理展示队列;减少动态效果时完整显示 |
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
@@ -78,6 +79,12 @@ tests/ # 按业务模块组织的回归
## 模块边界与依赖方向
连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。
报价支持同时比较最快和最便宜方案,推荐追问保留货物条件并按预算、时效分别列出选择。明确指定渠道会替换先前的比较选择。`quote_question_text` 从混合时效与赔付问句中提取报价条件,`timing_liability` 只引用当前匹配渠道已有的时效赔偿条款,其他费用由人工核实。独立理赔话题保留原有话题边界,其他目的地的上下文需先确认国家再使用美国渠道。
报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。
- 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。
- `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。
- `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。
@@ -113,7 +120,7 @@ tests/ # 按业务模块组织的回归
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。
- 登录后的订单数量由 `parse_count_query` / `order_query.parse_query` 识别,汇总、筛选和列表共用 `order_listing.read_orders` 的服务端 `tenant_filter` 交集与同一只读快照;`count_orders` 保留原聚合调用契约。默认给总数、已登记入仓码数及最多5条按来源更新时间排序的摘要;支持业务状态、明确标记的异常和当前实际节点。未指定节点的轨迹统计按订单分组,每票只计一次,多柜不同进度单列,预计节点不推进进度,业务完成不当作签收。未知条件不默认为全部,也不交给模型猜数。内部 `_customer_data` 和 `_order_list_state` 在发送前移除,事实历史绑定客户与范围;无新增公开API。
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
@@ -141,6 +148,28 @@ tests/ # 按业务模块组织的回归
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示20票并保护其会话历史;未知筛选条件不默认为全部订单。改址/取消尚无业务写入接口,只明确引导人工,不声称修改成功。输入明确的转人工指令复用现有申请接口,失败保留草稿,不增加SSE事件类型。
`quote_allocations.py` 只识别明确分配给各仓的重量、箱数及箱规,逐仓计费及计算每箱最低重量;仅给总重和多个仓码时保留比较语义,不擅自分配重量。`quote_context.py` 在当前会话的有效上下文内按时间顺序应用更正,保留已算出的总重:重量纠正会清除旧分箱明细,单改箱数时统一箱重可沿用,混合箱重需重新确认;“总重不变”优先于旧每箱重量,新尺寸不沿用旧体积。按方报价不把按公斤的材质费直接乘CBM,金额未确定时明确单列待核算项。
`shipping_faq.py` 从激活版本回答交仓、标签、报关资料及查验费用问题;具体船期和计划发货日期需人工核对,不使用旧表日期承诺必达。`order_explanations.py` 只解释授权订单的业务状态和实际节点,不把预计节点当实际位置,不推断逐箱签收或亚马逊上架。文字事实回复与订单卡片同样绑定客户及范围;催单或拦截不声称已提交。本轮不扩展售后理赔规则。
`customer_faq.py` 在报价分流前处理下单、短信查单、开票、导出及附件读取等能力说明,不生成业务操作成功的假回复。美国常见费用/毛净重/议价/总价追问由当前价表直接回答;`quote_scope.py` 将尚未匹配的美国咨询保留为明确的核对回复,不送入旧欧洲/英国知识库。明确切换其他国家时仍走该地区原有分流,理赔规则未扩展。
`quote_edits.py` 先移除被明确否定的仓码或交货仓,再解析替换条件。共享尺寸解析支持英寸转换和口语长宽高,“一个箱子”可形成单箱条件;总计立方米不作为尺寸单位“米”。
`order_listing.py` 负责数量摘要、查看全部和翻页。数量摘要最多5票,直接翻页继续每页5票;明确列出订单或“查看全部”按每页20票。`chat_history.order_list_state` 保存筛选、页码、页大小和成功回复的消息ID,绑定客户与范围;只保存仍为最新消息的状态,迟到回复不能覆盖新话题。中间插问同条件总数保留列表页码,改筛选重置,新话题停止沿用。每次重新查询授权数据,不缓存数量;计数及分页来自同一只读快照,无有效上下文先引导查询,页码有界。
`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。
用户询问“订单概览”“我的订单情况”时,`answer_overview` 在现有聊天流中回复,沿用订单事实的登录校验与会话保存。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。结果仅出现在聊天消息及对应历史中,登录和恢复登录只更新身份状态。
语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。
共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。
已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。
铁铝附加费只按铁铝货物计费重计算。`quote_material.py` 区分整票铁铝、部分铁铝与未明确材质的金属,并将明确的“铁铝部分计费重”从整票毛重解析中分离。部分货物未提供计费重时不按整票加费;提供明确基数后可将该项人民币费用加到公斤或按方运费中。更改整票重量、箱数或箱规后清除旧的部分计费重,防止沿用过期基数。未明确金属是否属于铁铝时先核实材质,不把所有金属都认作铁铝。
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
+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`。
以下参数由私有灰度配置提供,实际地址和凭据值不写入源码:
| 参数 | 含义 |
+11 -3
View File
@@ -91,14 +91,18 @@ def create_chat_stream(msg, conv_id):
return (sse(order_login_response()) + sse({'d':1}), {})
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id)
if order_answer is None:
from .customer_faq import answer_capability
order_answer = answer_capability(msg)
if order_answer is None:
order_answer = answer_quote(msg, conv_id)
if order_answer is None and re.search(r'(?:转|联系|接入|找).*人工|人工客服',msg):
order_answer = {'t':'点击“联系人工”可发起人工客服申请;非工作时间可留言。'}
if order_answer is None and re.search(r'照片|图片',msg) and re.search(r'报价|价格|判断|识别',msg):
order_answer = {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
if order_answer is None:
from .quote_scope import answer_us_fallback
order_answer = answer_us_fallback(msg,conv_id)
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
@@ -111,6 +115,7 @@ def create_chat_stream(msg, conv_id):
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
if order_answer is not None:
customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer
order_list_state=order_answer.pop('_order_list_state',None)
def track_stream():
if customer_data:
@@ -121,7 +126,10 @@ def create_chat_stream(msg, conv_id):
save_message(conv_id, 'user', msg)
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
if 'card' in order_answer else order_answer['t'])
save_message(conv_id, 'assistant', content)
message_id=save_message(conv_id, 'assistant', content)
if order_list_state is not None:
from .order_listing import save_state
save_state(conv_id,order_list_state,message_id)
yield sse(order_answer)
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
+8 -1
View File
@@ -14,6 +14,13 @@ def default_config():
local_config = dotenv_values(ROOT_DIR / '.env', encoding='utf-8-sig', interpolate=False)
api_key = os.environ.get('DEEPSEEK_API_KEY', local_config.get('DEEPSEEK_API_KEY'))
rate_limit_value = os.environ.get('RATE_LIMIT_PER_MIN', local_config.get('RATE_LIMIT_PER_MIN', '600'))
try:
rate_limit_per_min = int(rate_limit_value)
except (TypeError, ValueError):
raise ValueError('RATE_LIMIT_PER_MIN must be a positive integer') from None
if rate_limit_per_min < 1:
raise ValueError('RATE_LIMIT_PER_MIN must be a positive integer')
return {
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
@@ -40,7 +47,7 @@ def default_config():
'MAX_CONTEXT': 4,
'MAX_ANSWER_CHARS': 800,
'MAX_ANSWER_CHARS_EN': 1400,
'RATE_LIMIT_PER_MIN': 60,
'RATE_LIMIT_PER_MIN': rate_limit_per_min,
'ALLOWED_EXT': set(ALLOWED_EXT),
'MAX_UPLOAD_MB': MAX_UPLOAD_MB,
'HOST': '0.0.0.0',
+30
View File
@@ -0,0 +1,30 @@
"""Describe supported customer actions without inventing operational integrations."""
import re
def is_acknowledgement(message):
text=re.sub(r'[\s,,。.!!??]','',message).lower()
return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|'
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):
return {'t':'当前请使用客户账号登录后,按入仓码查询订单;暂不支持手机号或短信验证码查单。没有入仓码可联系人工核实订单归属。'}
if re.search(r'下单(?!号|时间|日期)|订舱|生成订单|创建订单|预约提货|安排提货|\b(?:place|create)\s+(?:an?\s+)?order\b',message,re.I):
return {'t':'下单、订舱和安排提货需要联系人工办理。当前可查询报价及已关联订单,尚未创建订单或安排提货;请提供目的仓、品名、箱数、重量和尺寸供人工核对。'}
if re.search(r'开(?:发票|票)|发票.*(?:税点|几个点|怎么开|申请)|月结|账期|付款账号|收款账号|银行账号|打款|(?:怎么|如何)付款',message):
return {'t':'开票税点、月结资格及付款方式需要由专属业务员确认。请联系人工核对对应订单和结算信息;当前聊天不提供未经确认的税点或收款账号。'}
if re.search(r'导出.*(?:订单|运单|Excel)|(?:订单|运单).*导出',message,re.I):
return {'t':'当前聊天暂不提供订单Excel导出,可联系人工核对导出需求。可先查看账号内订单列表,或发送入仓码查询单票详情。'}
if re.search(r'Excel|表格',message,re.I) and re.search(r'直接读|自动读|读取|识别|读里面',message):
return {'t':'当前聊天不会自动读取Excel附件里的箱规。请将每箱重量、长宽高及目的仓码粘贴为文字,我可据此核算;批量表格可交人工整理。'}
if re.search(r'换.*手机|换.*设备|跨设备|清除.*(?:浏览器|缓存)',message) and re.search(r'聊天|记录|历史',message):
return {'t':'当前聊天记录与原浏览器的访客身份关联,换手机或清除浏览器数据后不会自动同步恢复。请先在原设备查看;客户账号目前用于订单权限校验,尚未提供跨设备聊天同步。'}
return None
+4
View File
@@ -54,6 +54,8 @@ def init_db():
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
if 'customer_id' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
if 'order_list_state' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN order_list_state TEXT')
if 'customer_scope' not in cols:
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
if 'customer_tenant_id' in cols:
@@ -232,6 +234,7 @@ def save_message(conv_id, role, content):
if not inserted:
conn.close()
return 'deleted'
message_id=cur.lastrowid
if role == 'user':
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
if row and (not row[0] or row[0] == '新对话'):
@@ -239,6 +242,7 @@ def save_message(conv_id, role, content):
cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id))
conn.commit()
conn.close()
return message_id
def last_user_question(conv_id):
+4 -1
View File
@@ -7,11 +7,14 @@ from flask import current_app, jsonify, request
def rate_limit():
path = request.path
if request.endpoint == 'pages.index' and request.method in ('GET', 'HEAD'):
return None
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
return None
ip = request.remote_addr or 'unknown'
now = time.time()
hits = current_app.extensions['rate_hits'][ip]
bucket = (ip, 'chat') if request.endpoint == 'chat.stream_chat' else ip
hits = current_app.extensions['rate_hits'][bucket]
while hits and now - hits[0] > 60:
hits.pop(0)
if len(hits) >= current_app.config['RATE_LIMIT_PER_MIN']:
+86 -101
View File
@@ -14,7 +14,7 @@ from .customer_service import order_login_response
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
'history_json', 'tracking_json', 'exception_reason', 'data_note', 'source_updated_at', 'snapshot_at')
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
@@ -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):
@@ -39,13 +39,15 @@ def init_order_table(connection):
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
order_role TEXT, parent_order_no TEXT,
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', exception_reason TEXT, data_note TEXT,
source_updated_at TEXT, snapshot_at TEXT,
UNIQUE(tenant_id, order_no)
)''')
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
if 'tracking_json' not in columns:
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
if 'exception_reason' not in columns:
connection.execute('ALTER TABLE order_test_data ADD COLUMN exception_reason TEXT')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
@@ -62,6 +64,10 @@ def import_records(connection, records, replace=False):
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
row['order_no'] = str(row['order_no']).strip()
row['status'] = str(row['status'] or 'DRAFT')
if row['exception_reason'] is not None:
if not isinstance(row['exception_reason'],str) or len(row['exception_reason'])>2000:
raise ValueError('exception_reason must be customer-visible text up to 2000 characters')
row['exception_reason']=row['exception_reason'].strip() or None
history = row['history_json'] or []
if isinstance(history, str): history = json.loads(history)
if not isinstance(history, list): raise ValueError('history_json must contain a list')
@@ -78,7 +84,8 @@ def import_records(connection, records, replace=False):
continue
columns = ','.join(FIELDS)
placeholders = ','.join('?' for _ in FIELDS)
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no')
and (key!='exception_reason' or key in source))
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
inserted += 1
@@ -131,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
@@ -142,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
@@ -179,92 +187,40 @@ def count_orders(tenant_ids, status=None):
def parse_count_query(message):
"""Recognize bounded total/status questions without ignoring unknown filters."""
if is_lookup_help(message):
return None
noun = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
count = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
if not re.search(noun,message,re.I) or not re.search(count,message,re.I):
return None
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):
return None
remaining = message
statuses = []
for phrase, status in (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')):
if phrase in remaining:
statuses.append(status)
remaining = remaining.replace(phrase,'')
remaining = re.sub(count+'|'+noun,'',remaining,flags=re.I)
remaining = re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
r'\b(?:what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
return {'status':statuses[0] if statuses else None, 'unsupported':bool(remaining or len(set(statuses)) > 1)}
if is_lookup_help(message):return None
from .order_query import parse_query
return parse_query(message)
def answer_count_query(intent, tenants):
if not tenants:
return order_login_response()
if intent['unsupported']:
return {'t':'暂不支持按这个条件统计。可以查询订单总数,或按草稿、已提交、履约中、已完成、已取消等业务状态统计。'}
try:
counts = count_orders(tenants,intent['status'])
except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if intent['status']:
text = f"您当前有 {counts['total']} 票业务状态为“{STATUS_LABELS[intent['status']]}”的订单"
else:
text = f"您当前共有 {counts['total']} 票订单"
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
def answer_count_query(intent, tenants,conv_id=None):
from .order_listing import answer_summary
return answer_summary(intent,tenants,conv_id)
def answer_list_query(message, tenants):
"""Reuse bounded status parsing; list reads require an explicit customer scope."""
if is_lookup_help(message) or not re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I):
return None
if not re.search(r'列出|列表|全部|所有|\blist\b',message,re.I):
return None
if not tenants:
return order_login_response()
question=re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I)
intent=parse_count_query(question)
if intent is None or intent['unsupported']:
return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'}
scope=tenant_filter(tenant_ids=tenants)
if scope is None:
return order_login_response()
from .db import connect
connection=None
try:
connection=connect()
sql='SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data WHERE 1=1'+scope[0]
values=scope[1]
if intent['status']:
sql+=' AND status=?';values=values+[intent['status']]
rows=connection.execute(sql+' ORDER BY id DESC LIMIT 21',values).fetchall()
if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True}
lines=['您当前的订单列表'+('(先列出20票)' if len(rows)>20 else '')+':']
for number,inbound,status,warehouse in rows[:20]:
lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(status,status)};目的仓:{warehouse or "未登记"}')
lines.append('发送入仓码可查询该票订单和物流轨迹。')
return {'t':'\n'.join(lines),'_customer_data':True}
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
finally:
if connection is not None:connection.close()
def answer_list_query(message, tenants, conv_id=None):
from .order_listing import answer_list
return answer_list(message,tenants,conv_id)
def extract_reference(message):
# Explicit postal/monetary fields are not order identifiers, even if invalid.
message = re.sub(r'(?:手机号|手机号码|电话号码|联系电话|电话)\s*[::是为]?\s*(?:\+?86[-\s]?)?1[3-9]\d{9}', ' ', message)
message = re.sub(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*[0-9-]+', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*(?:万)?(?:USD|RMB|CNY|美元|美金|人民币|元)(?![A-Za-z0-9_-])',' ',message,flags=re.I)
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message)
from .pricing import DIMENSION_PATTERNS
for pattern in DIMENSION_PATTERNS:
def remove_dimensions(match):
end=match.start()+len(match[0].rstrip())
before=message[match.start()-1:match.start()] if match.start() else ''
after=message[end:end+1]
return match[0] if re.search(r'[A-Za-z0-9_.-]',before+after) else ' '
message=pattern.sub(remove_dimensions,message)
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
r'(?:\s*(?:inches|inch|in|cm|mm|m|英寸|厘米|毫米|米|["″]))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kilograms?|kgs?|grams?|g|tons?|lbs?|磅|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
@@ -341,7 +297,7 @@ def is_tracking_request(message):
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
r'(?:已)?(?:清关|开船|到港|出库|提柜)(?:了)?(?:吗|没|了吗|没有)?|签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
@@ -351,6 +307,9 @@ def is_tracking_request(message):
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
from .order_explanations import detail_question
if detail_question(message) and not re.search(r'报价|价格|多少钱|公斤|\bKG\b|海派|海卡',message,re.I):
return True
if is_lookup_help(message):
return False
if is_tracking_request(message):
@@ -374,14 +333,28 @@ 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:
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']]
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)
return answer_count_query(count_intent,tenants,conv_id)
if not references:
listed=answer_list_query(message,tenants)
listed=answer_list_query(message,tenants,conv_id)
if listed is not None:return listed
mutation=bool(re.search(r'改址|修改.*(?:收件|地址|订单)|收件地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
mutation=bool(re.search(r'改址|改.*(?:收货|收件).*地址|修改.*(?:收件|地址|订单)|(?:收货|收件)地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
if references and not tenants:
return order_login_response()
if len(references) > 1:
@@ -389,30 +362,37 @@ 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':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
if detail_question(message) and not is_lookup_help(message):
return order_login_response() if not tenants else {'t':'请提供入仓码,我先核对该票订单的实际物流记录。'}
if re.search(r'哪些.*(?:订单|运单)|(?:订单|运单).*(?:筛选|还没开船|未发货|已发货|未签收)',message) and not is_lookup_help(message):
return order_login_response() if not tenants else {'t':'暂不支持按这个物流条件批量筛选订单。可按业务状态查看列表,或提供入仓码逐票核对实际物流节点。'}
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
@@ -423,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:
@@ -434,4 +416,7 @@ def answer_query(message, conv_id):
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True}
return {'card': build_card(matches[0])}
card=build_card(matches[0])
explanation=explain_order(message,matches[0],card)
if explanation:return {'t':explanation,'_customer_data':True}
return {'card':card}
+55
View File
@@ -0,0 +1,55 @@
"""Explain recorded order facts without inferring delivery or operational writes."""
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'异常原因|为什么异常|什么异常|多重|重多少|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|'
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
def general_explanation(message):
if re.search(r'我的|这票|这单|这个订单|该订单',message):return None
if re.search(r'已完成|履约中|上架',message) and re.search(r'是什么意思|是否代表|是不是|等于|有什么区别',message):
return '“履约中”“头程订单已完成”属于业务状态;出库、开船、到港等以实际物流节点为准。业务完成不等于收件人签收,签收也不等于亚马逊已上架;上架需以亚马逊入库记录核实。'
return None
def explain_order(message,row,card):
if not detail_question(message):return None
reference=row.get('inbound_code') or row['order_no']
lines=[f'入仓码 {reference}。' if row.get('inbound_code') else f'订单号 {reference}。']
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):
return lines[0]+'当前订单未接入付款状态,不能根据业务状态判断是否付款,请联系人工核对账单。'
if '快递单号' in message:
return lines[0]+'当前未记录可核实的UPS/FedEx尾程快递单号,入仓码和内部订单号不能直接当作快递单号,请联系人工核对。'
lines.append(f'登记的业务状态为“{card["status"]}”。')
actual=card.get('nodes',[]) if card.get('node_kind')=='logistics' else []
if actual:
latest=actual[0]
lines.append('最近一条实际物流记录:'+' '.join(str(latest.get(k) or '') for k in ('time','text','place')).strip()+'。')
else:lines.append('当前没有已记录的实际物流节点,不能根据业务状态推断货物位置。')
if re.search(r'上架|已完成|签收|送达',message):
lines.append('头程业务完成不等于收件人签收;当前节点不能用于确认亚马逊上架。上架结果需核对亚马逊入库记录。')
if re.search(r'没更新|未更新|没有更新|不更新',message):
lines.append('未更新期间的实际位置和原因无法从现有记录确定,可提供入仓码联系人工核查。')
if re.search(r'发货|发走|出库|开船',message):
lines.append('计划发货或开船安排需联系人工核对,不能根据业务状态承诺未来发货日期。')
if re.search(r'少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收',message):
if row.get('package_count') is not None:lines.append(f'登记总件数为{row["package_count"]}件。')
lines.append('当前没有逐箱签收明细,不能把整票业务状态当作每箱均已送达,请联系人工核实各箱情况。')
return '\n'.join(lines)
+142
View File
@@ -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':'订单信息暂时无法读取,请稍后重试。'}
+296
View File
@@ -0,0 +1,296 @@
"""Scoped summaries, five-row previews and durable conversational list pages."""
from collections import Counter,deque
import json
import re
import sqlite3
from .db import connect
from .order_query import parse_query,filters,STAGE_LABELS
from .order_tracking import current_stage
def page_command(message):
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:请)?(?:订单)?第(\d{1,5})页',text)
if match:return int(match.group(1))
if re.fullmatch(r'(?:请|继续)?(?:下一页|下页|继续显示|接着显示(?:剩下的)?订单)',text):return 'next'
if re.fullmatch(r'(?:请)?(?:上一页|上页)',text):return 'previous'
return None
def valid_filters(query):
from .order_data import STATUS_LABELS
return (isinstance(query,dict) and set(query)=={'status','stage','exception'}
and (query['status'] is None or query['status'] in STATUS_LABELS)
and (query['stage'] is None or query['stage'] in STAGE_LABELS)
and isinstance(query['exception'],bool))
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:
row=connection.execute('SELECT order_list_state FROM chat_history WHERE id=? AND visitor_id=? '
'AND customer_id=? AND customer_scope=?',
(conv_id,visitor_id(),current_customer_id(),current_scope_key())).fetchone()
state=json.loads(row[0]) if row and row[0] else None
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
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
except (ValueError,TypeError):return None
finally:connection.close()
def save_state(conv_id,state,message_id):
"""Called only after the reply and customer binding have been persisted."""
from .customer_service import current_customer_id,current_scope_key
from .visitor_service import visitor_id
if not conv_id or type(message_id) is not int:return
connection=connect()
try:
state=dict(state)
state['message_id']=message_id
connection.execute('UPDATE chat_history SET order_list_state=? WHERE id=? AND visitor_id=? '
'AND customer_id=? AND customer_scope=? '
'AND NOT EXISTS(SELECT 1 FROM messages WHERE conv_id=? AND id>?)',
(json.dumps(state,ensure_ascii=False),conv_id,visitor_id(),current_customer_id(),current_scope_key(),conv_id,message_id))
connection.commit()
finally:connection.close()
def scoped_orders(connection,tenants,status=None):
"""Use one authorized projection and normalization for lists and overviews."""
from .order_data import tenant_filter
scope=tenant_filter(tenant_ids=tenants)
if scope is None:raise ValueError('Explicit customer scope required')
where=' WHERE 1=1'+scope[0]
values=scope[1]
if status:
where+=' AND status=?'
values=values+[status]
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason '
'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values)
for row in rows:
item=dict(row)
item['exception_reason']=(item['exception_reason'] or '').strip()
item['stage']=current_stage(item.pop('tracking_json'))
yield item
def read_overview(tenants):
"""Summarize the customer's current orders in one database snapshot."""
connection=connect()
try:
connection.row_factory=sqlite3.Row
connection.execute('BEGIN')
total=processing=exceptions=0
stages=Counter()
abnormal_rows=[]
processing_rows=[]
for row in scoped_orders(connection,tenants):
total+=1
abnormal=bool(row['exception_reason'])
if abnormal:
exceptions+=1
if len(abnormal_rows)<5:abnormal_rows.append(row)
if row['status']=='PROCESSING':
processing+=1
stages[row['stage']]+=1
if not abnormal and len(processing_rows)<5:processing_rows.append(row)
return {'total':total,'processing':processing,'exceptions':exceptions,
'stages':[{'code':code,'count':stages[code]} for code in STAGE_LABELS if stages[code]],
'orders':(abnormal_rows+processing_rows)[:5]}
finally:
connection.close()
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) 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)
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,
'counts':[sum(group.values()) for group in comparison_groups],'comparison_groups':comparison_groups}
finally:connection.close()
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"])}'
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
def unsupported(listing=False):
return {'t':f'暂不支持按这个条件{"列出订单" if listing else "统计"}。可以查询订单总数、业务状态、明确标记的异常,或按当前实际物流节点统计;请一次指定一个节点。'}
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()
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,comparisons=comparisons)
total=data['total']
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('已记录的异常原因(最多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':'订单信息暂时无法读取,请稍后重试。'}
def answer_list(message,tenants,conv_id=None):
from .order_data import is_lookup_help,STATUS_LABELS
from .customer_service import order_login_response
command=page_command(message)
expand=message.strip(' ,,。.!!??') in ('查看全部','查看全部订单')
initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I)
and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I))
if command is None and not initial and not expand:return None
if not tenants:return order_login_response()
try:
previous=load_state(conv_id)
if command is not None or expand:
if not previous:return {'t':'请先在当前对话中发送“列出我的订单”或查询订单数量,再翻页查看。'}
query=previous['query'];size=20 if expand else previous['size']
page=1 if expand else int(command) if isinstance(command,int) else max(1,previous['page']+(1 if command=='next' else -1))
if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'}
else:
intent=parse_query(message,listing=True)
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,
'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 ''
extra=(';当前节点:'+STAGE_LABELS[query['stage']] if query['stage'] else '')+(';明确标记异常' if query['exception'] else '')
lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}{extra}):',f'共{total}票,当前显示第{(page-1)*size+1}至{(page-1)*size+len(data["rows"])}票。']
lines.extend(row_text(row) for row in data['rows'])
lines.append('回复“下一页”查看后续订单。' if page*size<total else '当前已是最后一页。')
lines.append('发送入仓码可查询该票订单和物流轨迹。')
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
+74
View File
@@ -0,0 +1,74 @@
"""Recognize explicit, whitelisted order summary and list filters."""
import re
from .order_tracking import NODE_LABELS
NODE_PHRASES = (
('DOMESTIC_RECEIVED',r'已入国内仓|已入仓'),
('LOADING_COMPLETED',r'已完成装柜|已装柜'),
('DEPARTED_WAREHOUSE',r'已离开国内仓库|已离仓|已出库'),
('AT_ORIGIN_PORT',r'重箱已进入起运港或码头|已到起运港|已进起运港'),
('LOADED_ON_VESSEL',r'已装船'),
('DEPARTED_ORIGIN_PORT',r'船舶已离开起运港|已开船|开船'),
('IN_TRANSIT',r'海运途中|运输中|在途|海上'),
('ARRIVED_DESTINATION_PORT',r'船舶已到目的港|已到目的港|已到港|到港'),
('DISCHARGED_FROM_VESSEL',r'已卸船、未清关|卸船未清关|已卸船|卸船'),
('PICKUP_READY',r'已清关、可提柜|已清关|清关完成|可提柜'),
('PICKED_UP',r'已提柜|提柜'),
('EMPTY_RETURNED',r'已还柜|还柜'),
('NO_ACTUAL',r'没有实际轨迹|暂无实际节点|没有轨迹|无轨迹'),
('MIXED',r'多柜进度不一致|多柜进度不同'),
)
STAGE_LABELS = {code:labels[1] for code,labels in NODE_LABELS.items()}
STAGE_LABELS.update(NO_ACTUAL='暂无实际节点',MIXED='多柜进度不同')
STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'),
('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):
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.
unsupported=bool(re.search(r'未到港|没到港|还没|未开船|未发货|未签收|已签收|已送达|曾经|曾到|到过|正常订单|无异常',remaining))
for code,pattern in NODE_PHRASES:
if re.search(pattern,remaining):
nodes.append(code);remaining=re.sub(pattern,'',remaining)
for phrase,status in STATUSES:
if re.search(re.escape(phrase),remaining,re.I):
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)
remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I)
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,
'exception':exception,'grouped':grouped,
'unsupported':bool(unsupported or remaining or len(set(statuses))>1 or len(set(nodes))>1)}
def filters(intent):
return {key:intent.get(key) for key in ('status','stage','exception')}
+25 -6
View File
@@ -33,14 +33,14 @@ def parse_time(value):
return None
def build_tracking(raw):
"""Latest observation corrects a milestone; forecasts never advance progress."""
def tracking_facts(raw):
"""Share the same corrections and actual/estimated split with aggregation."""
try:
facts = json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
return None
return {},{}
if not isinstance(facts, list):
return None
return {},{}
actual, estimated = {}, {}
for index, fact in enumerate(facts):
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
@@ -55,8 +55,27 @@ def build_tracking(raw):
candidate = (observation, index, when, fact)
if key not in target or candidate[:2] > target[key][:2]:
target[key] = candidate
if not actual and not estimated:
return None
return actual,estimated
def current_stage(raw):
actual,estimated=tracking_facts(raw)
progress={}
for (shipment,code) in actual:
if shipment not in progress or NODE_LABELS[code][0]>NODE_LABELS[progress[shipment]][0]:
progress[shipment]=code
shipments={shipment for shipment,_ in actual.keys() | estimated.keys() if shipment}
if shipments:
if not any(shipment in progress for shipment in shipments) and '' in progress:return progress['']
progress={shipment:progress.get(shipment,'NO_ACTUAL') for shipment in shipments}
codes=set(progress.values())
return next(iter(codes)) if len(codes)==1 else 'MIXED' if codes else 'NO_ACTUAL'
def build_tracking(raw):
"""Latest observation corrects a milestone; forecasts never advance progress."""
actual,estimated=tracking_facts(raw)
if not actual and not estimated:return None
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
multiple = len(shipments) > 1
+37 -17
View File
@@ -94,15 +94,36 @@ RATES = {
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
DIMENSION_NUMBER = r'(?<![\d.+-])(\d+(?:\.\d+)?)(?![\d.])'
DIMENSION_UNIT = r'\s*(英寸|毫米|厘米|公分|米|(?:inches|inch|in|cm|mm|m)(?=$|[^A-Za-z0-9²³]|[xX])|["″])?\s*'
DIMENSION_AXIS = DIMENSION_NUMBER + DIMENSION_UNIT
DIMENSION_PATTERNS = tuple(re.compile(pattern,re.I) for pattern in (
DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS,
r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高\s*'+DIMENSION_AXIS,
DIMENSION_AXIS+r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高',
))
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
return [float(value) for value in match.groups()] if match else None
def dimension_measurement(text):
"""Keep units attached to the three packaging axes, not product descriptions."""
matches=[match for pattern in DIMENSION_PATTERNS if (match:=pattern.search(text))]
if not matches:return None
match=min(matches,key=lambda item:item.start())
parts=match.groups()
values=[float(parts[index]) for index in (0,2,4)]
aliases={'英寸':'in','inches':'in','inch':'in','"':'in','″':'in',
'毫米':'mm','厘米':'cm','公分':'cm','米':'m'}
units=[aliases.get(value.lower(),value.lower()) if value else None for value in parts[1::2]]
default=next((unit for unit in reversed(units) if unit),'cm')
units=[unit or default for unit in units]
if len(set(units))==1:return values,units[0]
factors={'cm':1,'mm':0.1,'m':100,'in':2.54}
return [value*factors[unit] for value,unit in zip(values,units)],'cm'
def parse_dims(t):
measurement=dimension_measurement(t)
return measurement[0] if measurement else None
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
@@ -142,11 +163,6 @@ def parse_shipping_intent(text):
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
if not intent["dims"]:
# 支持“长100宽80高60”
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
if dl:
intent["dims"] = [float(x) for x in dl.groups()]
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
@@ -177,20 +193,24 @@ def parse_shipping_intent(text):
return intent
def detect_dim_unit(text):
"""识别尺寸单位:cm(默认)/ m / mm"""
"""Recognize explicit dimensions; aggregate CBM does not imply meters."""
measurement=dimension_measurement(text)
if measurement:return measurement[1]
low = text.lower()
if re.search(r'英寸|\binch(?:es)?(?![A-Za-z])|\d\s*in(?![A-Za-z0-9])|\d\s*["″]',low):
return 'in'
if re.search(r"毫米|\d\s*mm", low):
return "mm"
if re.search(r"米", low) and not re.search(r"厘米", low):
return "m"
if re.search(r"\d\s*cm|\d\s*厘米", low):
return "cm"
if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断
if re.search(r"\d\s*(?:m(?![A-Za-z0-9²³])|米)", low):
return "m"
return "cm"
def dims_to_cm(dims, unit):
"""统一换算成厘米,体积重公式按 cm 计算"""
if unit == 'in':
return [d * 2.54 for d in dims]
if unit == "m":
return [d * 100 for d in dims]
if unit == "mm":
+38
View File
@@ -0,0 +1,38 @@
"""Explicit weight-to-warehouse assignments; unassigned comparisons stay separate."""
from decimal import Decimal
import re
def allocations(text,codes,weight_pattern,to_kg):
if len(codes)<2:return None
code='('+'|'.join(re.escape(code) for code in sorted(codes,key=len,reverse=True))+r')(?![A-Za-z0-9])'
forward=weight_pattern.pattern+r'\s*(?:货物)?\s*(?:发到|发往|送到|发|到|去)?\s*'+code
reverse=code+r'\s*(?:[::]|分配|发|重)?\s*'+weight_pattern.pattern
entries=[];used=[]
for pattern,backwards in ((forward,False),(reverse,True)):
for match in re.finditer(pattern,text,re.I):
if any(match.start()<end and match.end()>start for start,end in used):continue
values=match.groups()
destination,value,unit=values if backwards else (values[2],values[0],values[1])
piece_weight=to_kg(value,unit)
weight_start=match.start(2 if backwards else 1)
carton=re.search(r'(第)?(\d+)\s*(?:箱|件)\s*(每(?:箱|件))?\s*$',text[:weight_start])
boxes=(1 if carton.group(1) else int(carton.group(2))) if carton else None
pieces=([{'count':boxes,'weight':str(piece_weight),'dims':None,'dim_unit':'cm','position':weight_start}]
if carton and (boxes==1 or carton.group(3)) else None)
weight=piece_weight*boxes if pieces else piece_weight
entries.append({'warehouse':destination.upper(),'weight':weight,'boxes':boxes,'packages':pieces})
used.append(match.span())
if len(entries)<2:return None
totals={}
for entry in entries:
code=entry['warehouse']
if code not in totals:
totals[code]=entry
continue
total=totals[code]
total['weight']+=entry['weight']
total['boxes']=total['boxes']+entry['boxes'] if total['boxes'] is not None and entry['boxes'] is not None else None
total['packages']=(total['packages']+entry['packages']) if total['packages'] and entry['packages'] else None
return [{**entry,'weight':str(entry['weight'])} for entry in totals.values()]
+53 -4
View File
@@ -6,10 +6,46 @@ import re
from .quote_fees import document, amount, material_fee, limit_fees
def timing_liability(intent,book,destinations):
from .quote_engine import match_rates
rates=[rate for destination in destinations for rate in match_rates(intent,book,destination)]
services=list(dict.fromkeys(rate['service'] for rate in rates))
lines=[]
for name in services:
terms=[rate['eta'] for rate in rates if rate['service']==name]
if all('无时效赔偿' in term for term in terms):
lines.append(name+'的渠道资料注明“无时效赔偿”。')
elif any('无时效赔偿' in term for term in terms):
lines.append(name+'部分匹配方案注明“无时效赔偿”,需按交仓地和目的仓核对适用条款。')
lines.append('未按参考时效到达所产生的其他费用,需由人工根据实际承运条款核实,当前无法确认赔付金额。')
return '\n'.join(lines)
def answer_business(intent,book):
from .quote_engine import number
from .quote_engine import number,exact_number
text=intent['text'];mode=intent.get('mode')
if mode=='weight_basis':
return '运费按计费重量计算,不是只看净重。实重按含包装的毛重,与材积重取较大值;美国海派材积重为长×宽×高(cm)÷6000,每箱不足12KG按12KG计费。海卡包税按对应公斤规则,不包税按方方案以实际体积和毛重÷363取大;请按所选方案核算。'
if mode=='rounding':
weight=(f'若{exact_number(intent["weight"])}KG已是计费重,就按{exact_number(intent["weight"])}KG计算。' if intent.get('weight') else '')
return '美国海派按原小数计费,不向上取整;每箱不足12KG仍按12KG计费。'+weight+'计费重先按各箱实重与材积重取大,再按渠道对应的重量门槛匹配档位。海卡等其他方案的进位方式需另行核对。'
if mode=='mixing':
return '请分别提供各产品的品名、材质、数量、重量,以及是否带电、带磁、液体或粉末,并说明是否放在同一箱。需先核对各品类的承运与申报要求,再确认混装方案,不能仅凭“两种产品”判断可以混装。'
if mode=='metal_scope':
from .quote_fees import material_charge
from .quote_engine import quantity
per_kg=material_fee(book)
if per_kg is None:return '铁铝附加费只按铁铝货物的计费重收取,当前未匹配到费率,请联系人工核对。'
lines=[f'铁铝附加费为{per_kg:g}元/KG,只按铁铝货物的计费重收取;一票只有部分铁铝货物时,不按整票计费重加收。']
unit='cbm' if intent.get('tax')=='excluded' and intent.get('cbm') else 'kg'
rate={'kind':'truck' if intent.get('kinds')==['truck'] else 'parcel','unit':unit,'service':''}
details=material_charge(intent,quantity(intent,rate),unit,book)
lines+=details['items']+details['notes']
if intent.get('metal_scope')!='partial' and details['items'] and not intent.get('dims'):
lines.append('以上按当前提供的重量条件估算,仍需核对各箱尺寸及材积重。')
return '\n'.join(dict.fromkeys(lines))
if mode=='currency':
return '当前海卡、海派运价中的“元/KG”和“元/CBM”按人民币(RMB/CNY)计;一件代发等资料中标注“$”“USD”“美金”的费用按美元计。不同币种分别列明,未提供结算汇率时不自动换算。'
if mode=='insurance':
@@ -74,10 +110,23 @@ def answer_business(intent,book):
return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。'
if mode=='fees':
lines=[]
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):
lines.append(f'当前资料铁铝制品附加费为{metal:g}元/KG。')
if intent.get('weight'):lines.append(f'先按已提供重量{number(intent["weight"])}KG计算该项:{number(intent["weight"])}×{metal:g}={number(Decimal(intent["weight"])*metal)}元;计费重确定后继续核算。')
from .quote_fees import material_charge
from .quote_engine import quantity
unit='cbm' if intent.get('tax')=='excluded' and intent.get('cbm') else 'kg'
charge=quantity(intent,{'kind':'truck' if intent.get('kinds')==['truck'] else 'parcel','unit':unit,'service':''})
details=material_charge(intent,charge,unit,book)
lines.append(f'铁铝附加费为{metal:g}元/KG,只按铁铝货物的计费重收取。')
lines+=details['items']+details['notes']
if re.search(r'超重|超尺寸|超长',text):
raw=document(book,('超重收',))
fee=amount(raw,r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
@@ -93,6 +142,6 @@ def answer_business(intent,book):
if remote is not None and extended is not None:
lines.append(f'当前美国海派资料:偏远地址加{remote:g}元/KG,超偏远地址加{extended:g}元/KG,遥远地址单询;海派每箱不足12KG按12KG计费。')
lines.append('报价中的包税/不包税是不同费率口径;材质、报关申报、偏远及超重超尺寸等按条件单列,不代表所有服务费用都已包含。')
if metal is not None:lines.append(f'铁铝制品附加费{metal:g}元/KG;多项费用分别列明,同箱超限费用只取最高项。')
if metal is not None:lines.append(f'铁铝制品附加费{metal:g}元/KG,只按铁铝货物计费重收取;多项费用分别列明,同箱超限费用只取最高项。')
return '\n'.join(lines)
return None
+38
View File
@@ -0,0 +1,38 @@
"""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,select_cheapest_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 = []
cheapest=intent.get('selection')=='fastest_cheapest'
comparison=(select_cheapest_rates(intent,book,candidates,fees) if cheapest
else select_timed_rates(candidates,slowest=True))
for selected in (select_timed_rates(candidates),comparison):
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)
pair='最快与最便宜方案' if cheapest else '快慢方案'
lines.append(f'- {label},{pair}可计算运费差额为{difference}元。')
return lines
+219
View File
@@ -0,0 +1,219 @@
"""Apply quote edits in chronological order, including derived carton quantities."""
from copy import deepcopy
from decimal import Decimal
from .quote_intent import kg,MISSING_WEIGHT_UNIT
from .quote_edits import ADJUSTMENT_QUESTION,VOLUME_QUESTION
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
def apply_quantity_change(result,current,previous):
from .quote_engine import exact_number
change=current.get('quantity_change')
if not change:return False
if change['kind']=='volume':
updated=Decimal(current['cbm']) if current['cbm'] is not None else None
mismatch=False
if not change['uncertain'] and previous['cbm'] is not None and not previous.get('volume_pending'):
expected=Decimal(previous['cbm'])+Decimal(change['delta'])
if updated is not None and updated!=expected:
mismatch=True
result['errors'].append('增减体积与您提供的新总体积不一致,请确认调整后的总体积。')
else:updated=expected
if change['uncertain'] or updated is None or updated<=0 or mismatch:
result['volume_pending']=True
result['errors'].append(VOLUME_QUESTION)
else:
result['cbm']=str(updated)
result['volume_pending']=False
result['quantity_note']=f'按本次增减更新,总体积为{exact_number(updated)}CBM。'
return False
question=ADJUSTMENT_QUESTION
result['quantity_question']=question
result['quantity_uncertain']=True
if change['kind']=='boxes' and (previous['cbm'] is not None or previous.get('volume_pending') or current['cbm'] is not None):
result['volume_pending']=True
if change['uncertain'] or previous.get('allocations'):
result['errors'].append(question)
return False
if change['kind']=='weight':
if previous.get('weight') is None or previous.get('quantity_uncertain') or previous.get('pending_weight'):
if current['weight'] is None:
result['errors'].append(question)
return False
updated=Decimal(current['weight'])
else:
updated=Decimal(previous['weight'])+Decimal(change['delta'])
if current['weight'] is not None and Decimal(current['weight'])!=updated:
result['errors'].append('增减量与您提供的新总重不一致,请确认调整后的总重。')
return False
if updated<=0:
result['errors'].append('调整后的总重需要大于0,请确认增减量或新的总重。')
return False
result['weight']=str(updated)
else:
pieces=deepcopy(previous.get('packages') or [])
if not pieces or previous.get('quantity_uncertain') or previous.get('pending_weight'):
result['errors'].append(question)
return False
for piece in pieces:
if not piece.get('dims') and previous['dims']:
piece.update(dims=previous['dims'],dim_unit=previous['dim_unit'])
first=pieces[0]
uniform=all(Decimal(piece['weight'])==Decimal(first['weight'])
and all(piece.get(key)==first.get(key) for key in ('dims','dim_unit')) for piece in pieces)
if uniform:
first['count']=sum(piece['count'] for piece in pieces)
pieces=[first]
delta=change['delta']
additions=deepcopy(current.get('packages') or [])
target_count=sum(piece['count'] for piece in pieces)+delta
declared_count=change.get('declared_boxes')
if declared_count is not None and declared_count!=target_count:
result['errors'].append('增减箱数与您提供的新总箱数不一致,请确认调整后的箱数。')
return False
if declared_count is not None and additions and sum(piece['count'] for piece in additions)==target_count:
pieces=additions
elif delta>0 and additions:
if sum(piece['count'] for piece in additions)!=delta:
result['errors'].append(question)
return False
pieces.extend(additions)
elif uniform:
if delta<0 and additions and any(Decimal(piece['weight'])!=Decimal(first['weight'])
or piece.get('dims') and (piece['dims']!=first.get('dims') or piece['dim_unit']!=first.get('dim_unit')) for piece in additions):
result['errors'].append('减少箱子的重量与原分箱明细不一致,请提供更新后的逐箱明细。')
return False
first['count']+=delta
if first['count']<=0:
result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。')
return False
else:
result['errors'].append('原货物包含不同箱规,请提供更新后的逐箱数量和重量。')
return False
result['packages']=pieces
result['boxes']=sum(piece['count'] for piece in pieces)
result['weight']=str(sum(Decimal(piece['weight'])*piece['count'] for piece in pieces))
result['dims']=None
result['cbm']=None
if result['boxes']<=0 or any(piece['count']<=0 for piece in pieces):
result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。')
return False
if change.get('declared_weight') is not None and Decimal(change['declared_weight'])!=Decimal(result['weight']):
result['errors'].append('增减箱数推算的总重与您提供的新总重不一致,请确认更新后的分箱明细。')
return False
result['quantity_uncertain']=False
result['quantity_question']=None
verb='增加' if Decimal(change['delta'])>0 else '减少'
delta=exact_number(abs(Decimal(change['delta'])))
if change['kind']=='boxes':
old_count=sum(piece['count'] for piece in previous['packages'])
result['quantity_note']=f'按原{old_count}箱{verb}{delta}箱核算,共{result["boxes"]}箱。'
elif previous['weight'] is not None and not previous.get('quantity_uncertain'):
result['quantity_note']=f'按原总重{exact_number(previous["weight"])}KG{verb}{delta}KG核算。'
else:result['quantity_note']='按您确认的新总重核算。'
return True
def merge_intent(current,previous):
result=deepcopy(current)
excluded={'mode','selection','clear_selection','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') and not current.get('services'):
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('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if current.get('warehouse') or current.get('unknown_warehouses'):
excluded.update(('postal','zone','zones'))
elif current.get('postal'):
excluded.update(('warehouse','warehouses','unknown_warehouses','zone','zones'))
elif current.get('zone'):
excluded.update(('warehouse','warehouses','unknown_warehouses','postal'))
if any(current.get(k) for k in ('warehouse','postal','zone')) and not current.get('unknown_warehouses'):
excluded.add('unknown_warehouses')
if any(current.get(k) for k in ('warehouse','unknown_warehouses','postal','zone')) and not current.get('allocations'):
excluded.add('allocations')
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 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 (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:
excluded.add('metal_chargeable_weight')
for key,value in previous.items():
if result.get(key) is None and key not in excluded:result[key]=deepcopy(value)
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'] 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 or change and change['kind']=='volume') and not (change and change['kind']=='boxes'):
for piece in pieces:piece.update(dims=None,dim_unit='cm')
if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'):
result['quantity_uncertain']=True
result['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
elif len(pieces)==1:
pieces[0]['count']=result['boxes']
result['weight']=str(Decimal(pieces[0]['weight'])*result['boxes'])
result['quantity_note']=f'按每箱{pieces[0]["weight"]}KG不变,更新为{result["boxes"]}箱。'
else:
result['packages']=None
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
result['errors']=list(dict.fromkeys(result['errors']))
return result
+85
View File
@@ -0,0 +1,85 @@
"""Discard explicitly rejected quote conditions before parsing their replacements."""
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):
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):
return bool(re.search(r'会不会按|是不是按|是否按|向上进位|小数公斤|计费.*(?:进位|取整)|公斤.*(?:进位|取整)',text))
+115 -34
View File
@@ -20,6 +20,10 @@ def broad_zone(postal):
def variants(intent, book):
if intent.get('allocations'):
return [{**destination,'allocation':part} for part in intent['allocations']
for destination in variants({**intent,'allocations':None,'warehouse':part['warehouse'],
'warehouses':None,'postal':None},book)]
if len(intent.get('warehouses') or [])>1:
return [variant for warehouse in intent['warehouses'] for variant in
variants({**intent,'warehouse':warehouse,'warehouses':None,'postal':None},book)]
@@ -89,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':
@@ -112,77 +118,148 @@ def number(value, places=2):
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
def exact_number(value):
text=format(Decimal(value),'f')
return text.rstrip('0').rstrip('.') if '.' in text else text
def quantity_number(value,intent):
if Decimal(value)==Decimal(intent.get('weight') or '0') or Decimal(value).as_tuple().exponent>=-6:
return exact_number(value)
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 select_cheapest_rates(intent,book,rates,fees,zone=None):
from .quote_fees import charge_details
ranked=[]
best={}
for rate in rates:
amount=quantity(intent,rate)
if amount is None:continue
key=(zone or rate['zone'],rate['unit'],rate['tax'])
value=amount*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
ranked.append((rate,key,value))
best[key]=min(best.get(key,value),value)
return [rate for rate,key,value in ranked if value==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']:
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
lines.append(f"美国 {exact_number(intent['weight'])}KG 基础运费:")
else:
lines.append('美国线路报价:')
if len(intent.get('warehouses') or [])>1:
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
if intent.get('mode')=='recommend':
lines.append({'fastest':'按您优先时效的要求,可考虑下面的较快方案。',
'cheapest':'按您优先运费的要求,可考虑下面的较低价方案。'}.get(rate_selection,
'更看重时效可选下面的较快方案,更看重运费可选较低价方案。'))
if intent.get('allocations'):
lines.append('按各目的仓分票计算,箱规需逐票核对:')
if intent.get('metal_chargeable_weight') is not None:
lines.append('请分别提供各目的仓的铁铝货物计费重,整票铁铝基数不能重复套用到每个仓。')
elif len(intent.get('warehouses') or [])>1:
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
if intent.get('air_requested') and not intent.get('air_only'):
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
for destination, fees in zip(destinations, remote_fees):
rates = match_rates(intent, book, destination)
if intent.get('mode') in ('cheapest','fastest') and rates:
if intent['mode']=='cheapest':
eligible=[r for r in rates if quantity(intent,r) is not None]
best={}
for rate in eligible:
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
value=quantity(intent,rate)*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
best[key]=min(best.get(key,value),value)
rates=[r for r in eligible if quantity(intent,r)*Decimal(r['price'])+charge_details(intent,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('按资料中的参考时效,较快方案如下;提取不等于送达或签收:')
part=destination.get('allocation')
billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')}
if part else intent)
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 rate_selection=='cheapest' and rates:
rates=select_cheapest_rates(billing,book,rates,fees,zone=destination['zone']) 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','fastest_cheapest'):
selections=[]
if rate_selection in ('fastest','speed_compare','fastest_cheapest'):
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')))
if rate_selection=='fastest_cheapest':
selections.append(('最便宜参考方案',select_cheapest_rates(billing,book,rates,fees,zone=destination['zone'])))
lines.append('按相同区域、运输方式、计费单位及税费方式比较。' if rate_selection=='fastest_cheapest'
else '按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
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+('暂时无法确定,请提供货物重量和箱规。' if selection=='最便宜参考方案' else '暂时无法确定,匹配线路未列明可比较的天数。'))
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:
amount = quantity(intent,rate)
amount = quantity(billing,rate)
price = Decimal(rate['price'])
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
if amount is not None:
total = amount*price
details=charge_details(intent,rate,fees,book)
details=charge_details(billing,rate,fees,book)
total+=details['cny']
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
rate_text += f",{quantity_number(amount,billing)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
if details['items']:rate_text+='('+ ';'.join(details['items'])+')'
for note in details['notes']:
if note not in lines:lines.append(note)
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:
compared='最便宜' if rate_selection=='fastest_cheapest' else '最慢'
lines.append(f'以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去{compared}方案。')
lines.extend(differences)
else:
lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。')
if fees:
if fees['fee'] is None:
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
@@ -191,12 +268,16 @@ 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']:
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
if intent.get('mode')=='calculation':
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
if intent.get('mode')=='recommend' and rate_selection=='fastest_cheapest':
lines.append('您更看重预算还是时效?提供目的仓码或邮编、交仓地后,可继续缩小方案。')
text='\n'.join(lines)
return translate_quote(text) if intent.get('english') else text
+31 -5
View File
@@ -22,6 +22,35 @@ def material_fee(book):
return amount(text,r'铁铝制品[^\n]*?[¥¥]\s*(\d+(?:\.\d+)?)\s*/KG')
def material_charge(intent,charged,unit,book):
result={'cny':Decimal(0),'items':[],'notes':[]}
material=intent.get('material')
if material not in ('metal','unknown_metal'):return result
per_kg=material_fee(book)
if per_kg is None:
result['notes'].append('铁铝附加费费率尚未明确,当前金额未包含该项,请联系人工确认。')
return result
if material=='unknown_metal':
result['notes'].append('请先确认金属是否属于铁铝制品;铁铝附加费只按铁铝货物的计费重收取,当前金额未包含待核实的材质费用。')
return result
basis=intent.get('metal_chargeable_weight')
if basis is None and intent.get('metal_scope')=='partial':
result['notes'].append(f'铁铝附加费{per_kg:g}元/KG只按铁铝货物的计费重收取,不能按整票重量加费;请提供铁铝部分的计费重,当前金额未包含该项。')
return result
if basis is None and unit!='kg':
result['notes'].append(f'铁铝附加费为{per_kg:g}元/KG,当前按方运费未包含该项;需确认铁铝货物计费重量后单列,不能把CBM直接乘公斤附加费。')
return result
basis=Decimal(basis) if basis is not None else charged
if basis is None:return result
if unit=='kg' and charged is not None and basis>charged:
result['notes'].append('铁铝货物计费重高于当前整票可计算的计费重,请补充各箱实重和尺寸核对;当前金额未包含该项。')
return result
fee=basis*per_kg
result['cny']=fee
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{basis:g}KG={fee:g}元')
return result
def specimens(intent):
if intent.get('packages'):
return [{**p,'dims':p.get('dims') or intent.get('dims'),
@@ -83,11 +112,8 @@ def charge_details(intent,rate,remote,book):
fee=charged*Decimal(remote['fee']);result['cny']+=fee
result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元')
else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。')
if rate['unit']=='kg' and intent.get('material')=='metal':
per_kg=material_fee(book)
if per_kg is not None:
fee=charged*per_kg;result['cny']+=fee
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{charged:g}KG={fee:g}元')
metal=material_charge(intent,charged,rate['unit'],book)
result['cny']+=metal['cny'];result['items']+=metal['items'];result['notes']+=metal['notes']
if rate['kind']=='parcel':
limits=limit_fees(intent,book)
result['cny']+=limits['cny'];result['items']+=limits['items'];result['notes']+=limits['notes']
+139 -28
View File
@@ -8,9 +8,40 @@ from .pricing import parse_shipping_intent, detect_dim_unit, shipping_region
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)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|收费|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',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'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
TIMING_QUESTION = re.compile(r'时效|多久|几天|什么时候.*到|哪天.*到|何时.*到')
CLAIM_QUESTION = re.compile(r'理赔|索赔|赔(?:偿|付|钱|不赔|吗|嘛|么)|补偿|费用.*谁.*(?:承担|负责)')
def timing_with_claim(message):
if not CLAIM_QUESTION.search(message):return False
for part in re.split(r'[,,。!??;;]',message):
timing=TIMING_QUESTION.search(part)
claim=CLAIM_QUESTION.search(part)
if timing and (not claim or timing.start()<claim.start()):return True
return False
def quote_question_text(message):
"""Keep shipment conditions and timing when a separate clause asks about liability."""
if not timing_with_claim(message):return message
parts=[]
for part in re.split(r'[,,。!??;;]',message):
claim=CLAIM_QUESTION.search(part)
if claim:
if not TIMING_QUESTION.search(part):continue
part=part[:claim.start()]
if re.search(r'如果|要是|万一',part) and not TIMING_QUESTION.search(part):continue
parts.append(part)
return ','.join(parts)
def resumes_quote(message):
return bool(re.search(r'(?:回到|回头|继续)(?:刚才|之前|前面|上面)(?:的)?(?:美国)?(?:海运)?(?:报价|价格|运费)',message))
def chinese_number(text):
@@ -31,7 +62,7 @@ def chinese_number(text):
def normalize(text):
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|箱|件|立方米|立方|方|kg|cbm))',
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|(?:个)?箱|件|立方米|立方|方|kg|cbm))',
lambda m:chinese_number(m.group(1)),text,flags=re.I)
return re.sub(r'负(?=\d)','-',result)
@@ -45,60 +76,118 @@ def kg(value,unit):
return value
def request_selection(text):
fastest=bool(re.search(r'最快|快的|更快|快一?点|fastest',text,re.I))
slowest=bool(re.search(r'最慢|慢的|slowest',text,re.I))
cheapest=bool(re.search(r'最便宜|便宜的|便宜一?点|更便宜|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I))
fastest=fastest or bool(re.search(r'(?:时效|速度)优先|优先(?:时效|速度)|更看重(?:时效|速度)',text)
or re.fullmatch(r'(?:我选|选|选择)?(?:时效|速度)[。.!!??\s]*',text))
cheapest=cheapest or bool(re.search(r'(?:预算|运费|成本|价格)优先|优先(?:预算|运费|成本|价格)|更看重(?:预算|运费|成本|价格)',text)
or re.search(r'(?:想|要|希望|尽量|主要|优先|更在意)(?:能)?(?:省钱|便宜|节省运费|控制成本)',text)
or re.fullmatch(r'(?:我选|选|选择)?(?:预算|运费|成本|价格|省钱)[。.!!??\s]*',text))
if fastest and cheapest:return 'fastest_cheapest'
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text) and (fastest or slowest):return 'speed_compare'
if cheapest:return 'cheapest'
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'
from .quote_edits import weight_comparison
if weight_comparison(text):return 'rounding'
if re.search(r'毛重|净重|计费重.*(?:什么|区别)|材积重.*(?:什么|区别)',text):return 'weight_basis'
if re.search(r'混装|混箱|不同产品.*一箱',text):return 'mixing'
if (re.search(r'(?:铁|钢|铝|金属).*(?:附加|加费|加钱|整票)|整票.*(?:铁|钢|铝|金属)',text)
and not re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text)):
return 'metal_scope'
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
if re.search(r'最便宜|便宜的|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
from .shipping_faq import preparation_topic
if preparation_topic(text):return 'preparation'
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 '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.fullmatch(r'(?:我)?(?:选|选择|优先|更看重)?(?:时效|速度)(?:优先)?[。.!!??\s]*',text):return 'fastest'
if re.search(r'推荐|怎么走.*(?:好|合适)|走哪.*(?:好|合适)|选哪.*(?:好|合适)',text):return 'recommend'
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)
parsed=parse_shipping_intent(text)
text=normalize(quote_question_text(message))
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())
warehouses=list(dict.fromkeys(t for t in candidates if t in 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=[]
weights=[m for m in WEIGHT.finditer(text) if not text[:m.start()].rstrip().endswith(('x','X','×','*'))]
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','×','*'))]
weight=str(kg(*weights[0].groups())) if weights else None
boxes=re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)',text,re.I)
boxes=re.search(r'(\d+)\s*(?:个)?(?:箱(?:子)?|件|cartons?|boxes?)',quantity_text,re.I)
count=int(boxes.group(1)) if boxes else None
packages=[]
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
matches=list(package_re.finditer(text))
matches=list(package_re.finditer(quantity_text))
package_dimensions={}
for index,match in enumerate(matches):
ordinal,n,value,unit=match.groups()
segment=text[match.end():matches[index+1].start() if index+1<len(matches) else len(text)]
segment=quantity_text[match.end():matches[index+1].start() if index+1<len(matches) else len(quantity_text)]
dims=parse_shipping_intent(segment)['dims']
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
'dims':dims,'dim_unit':detect_dim_unit(segment)})
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
package_dimensions[match.start(3)]={'dims':dims,'dim_unit':detect_dim_unit(segment)}
for part in assigned or []:
for piece in part.get('packages') or []:
piece.update(package_dimensions.get(piece.pop('position'),{}))
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
if each and count and not packages:
packages=[{'count':count,'weight':str(kg(*each.groups())),'dims':parsed['dims'],'dim_unit':detect_dim_unit(text)}]
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.])',''),text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
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 len(weights)>1:
elif assigned:
weight=str(sum(Decimal(a['weight']) for a in assigned))
if set(a['warehouse'] for a in assigned)!=set(warehouses):errors.append('请分别提供每个目的仓的重量,避免遗漏分票。')
total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,quantity_text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与各目的仓分配重量不一致,请确认。')
elif len(weights)>1 and request_mode(text)!='rounding':
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
if assigned and packages and Decimal(weight)!=sum(Decimal(a['weight']) for a in assigned):
errors.append('分仓重量与分箱重量不一致,请分别确认每仓的箱数和重量。')
if any(kg(*w.groups())<=0 for w in weights):errors.append('重量必须大于 0,请确认有效重量。')
labeled=re.search(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*([0-9-]+)',text,re.I)
postal=None
@@ -110,7 +199,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()]
@@ -124,28 +213,50 @@ 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,'origin':origin,
'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),
'material':('ordinary' if re.search(r'普通货|普货|塑料|木制|非铁铝',text) else
('metal' if re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text) 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) or ('fastest_cheapest' if mode=='recommend' else None),
'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'],
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
'text':text}
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
def is_followup(message,intent):
message=quote_question_text(message)
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')))
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')))
+6
View File
@@ -15,9 +15,15 @@ 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:'),
('按相同区域、运输方式、计费单位及税费方式比较。','Options are compared within the same region, service kind, 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'),
('最便宜参考方案','Cheapest 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 '),
+33
View File
@@ -0,0 +1,33 @@
"""Separate metal chargeable-weight declarations from the shipment's gross weight."""
import re
from decimal import Decimal
def material_intent(text,weight_pattern,to_kg):
declarations=re.sub(r'铁铝(?:附加费|加费|费用)','',text)
metal=bool(re.search(r'铁制|钢制|铝制|铁铝|不锈钢|(?:铁|钢|铝)(?:件|货|扣|罐|盒|板|管|架|材)',declarations))
generic='金属' in text
ordinary=bool(re.search(r'普通货|普货|非铁铝',text))
partial=bool(re.search(r'只有|部分|其中|有些|只是|一部分',text) or
metal and re.search(r'塑料|木制|布料',text))
scope='partial' if partial and (metal or generic) else ('all' if re.search(r'全是|全部是|都是|整票是|货物是|换成|改成',text) and metal else None)
material=('ordinary' if '非铁铝' in text else 'metal' if metal else 'unknown_metal' if generic
else 'ordinary' if ordinary or re.search(r'塑料|木制',text) else None)
name=r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)(?:部分|货物)?(?:的)?'
patterns=(name+r'\s*计费重(?:量)?\s*(?:为|是|[::])?\s*'+weight_pattern.pattern,
name+r'\s*按\s*'+weight_pattern.pattern+r'\s*计费',
r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)部分(?:的计费重(?:量)?)?\s*'
r'(?:还是|仍然是|仍是|改为|改成|换成)\s*'+weight_pattern.pattern)
charged=None;cleaned=text;errors=[];oral=False
for index,pattern in enumerate(patterns):
matches=list(re.finditer(pattern,text,re.I))
for match in matches:
value=to_kg(*match.groups())
if charged is not None and Decimal(charged)!=value:errors.append('铁铝货物的计费重不一致,请确认。')
charged=str(value);scope='partial'
oral=oral or (index==2 and '计费重' not in match[0])
if not 0<value<=1000000:errors.append('请提供大于0的有效铁铝货物计费重。')
cleaned=cleaned[:match.start()]+' '*(match.end()-match.start())+cleaned[match.end():]
return {'material':material,'metal_scope':scope,'metal_chargeable_weight':charged,
'quantity_text':cleaned,'errors':errors,'metal_weight_update':True if oral else None}
+25
View File
@@ -0,0 +1,25 @@
"""Keep unanswered US shipping questions outside legacy regional model knowledge."""
import re
from .quote_intent import US
from .pricing import shipping_region
def answer_us_fallback(message,conv_id):
if re.search(r'理赔|赔偿|索赔|破损|丢失|退件|投诉',message):return None
if re.search(r'换个话题|另外问',message):return None
from .quote_edits import clean_conditions
current=clean_conditions(message)
active=bool(US.search(current) or re.search(r'美森|\bEXX\b',current,re.I))
if not US.search(current) and shipping_region(current) is not None:return None
if not active and conv_id:
from .quote_service import previous_quote_intents
active=any(intent['country']=='US' for intent in previous_quote_intents(conv_id,set()))
if not active:return None
from .quote_data import load_book
if not load_book():return {'t':'当前尚未包含可用的美国报价表,请提供目的地仓码或邮编及货物信息,联系人工确认。'}
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}',message))
if english:
return {'t':'Please specify the US route or destination warehouse/ZIP code and the point you want to confirm. Unlisted charges or service conditions require confirmation by customer support.'}
return {'t':'请说明您想确认的美国渠道、目的仓码或邮编,以及具体的费用或发货要求;未明确的服务条件需要联系人工核对。'}
+107 -41
View File
@@ -7,56 +7,81 @@ 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,quote_question_text,timing_with_claim
from .customer_faq import is_acknowledgement
def previous_quote_intents(conv_id,codes,resume=False,include_other=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 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
quote_text=quote_question_text(previous)
boundary=bool(re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',quote_text))
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'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',quote_text):
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':
if include_other:earlier_intents.append(earlier)
break
earlier_intents.append(earlier)
if not is_followup(previous,earlier):break
finally:
connection.close()
return earlier_intents
def intent_with_context(message, conv_id, book):
from copy import deepcopy
from .quote_context import merge_intent
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)
def destination_reset(value):
if value.get('warehouse') or value.get('unknown_warehouses'):return {'postal','zone','zones'}
if value.get('postal'):return {'warehouse','warehouses','unknown_warehouses','zone','zones'}
if value.get('zone'):return {'warehouse','warehouses','unknown_warehouses','postal'}
return set()
excluded=destination_reset(intent)
selected_destination=bool(excluded)
if selected_destination and not intent.get('unknown_warehouses'):excluded.add('unknown_warehouses')
if intent['transport'] or intent['kinds'] or intent['services']:excluded.update(('air_only','air_requested'))
if intent['air_only'] or intent['tax']=='excluded':excluded.add('kinds')
if intent.get('weight') is not None and not intent.get('packages'):excluded.add('packages')
explicit_dims=bool(intent['dims'])
if conv_id and is_followup(message,intent):
connection=connect()
try:
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
finally:connection.close()
for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
earlier=parse_intent(previous,codes)
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
if not selected_destination and destination_reset(earlier):
excluded.update(destination_reset(earlier));selected_destination=True
if intent['dims'] is None and earlier['dims']:
intent['dims']=earlier['dims'];intent['dim_unit']=earlier['dim_unit']
for key,value in earlier.items():
if key in ('mode','text','errors','dim_unit','dims'):continue
if intent.get(key) is None and key not in excluded:intent[key]=deepcopy(value)
if not is_followup(previous,earlier):break
if explicit_dims and intent.get('packages'):
for piece in intent['packages']:
piece['dims']=intent['dims'];piece['dim_unit']=intent['dim_unit']
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:intent['country']='US'
named_us_service=bool(re.search(r'美森|\bEXX\b',intent['text'],re.I)) and intent['country'] is None and not intent['warehouse']
earlier_intents=(previous_quote_intents(conv_id,codes,resume=resumes_quote(message),include_other=named_us_service)
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('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if earlier_intents:
state=earlier_intents[-1]
if state.get('metal_weight_update'):state['metal_chargeable_weight']=None
for update in list(reversed(earlier_intents[:-1]))+[intent]:
state=merge_intent(update,state)
intent=state
if (intent['warehouse'] or intent.get('unknown_warehouses') or named_us_service) and intent['country'] is None:
intent['country']='US'
return intent
def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', quote_question_text(message)):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
if not (US.search(message) or QUOTE_WORDS.search(message) or conv_id or
from .shipping_faq import preparation_topic
if not (US.search(message) or QUOTE_WORDS.search(message) or preparation_topic(message) or conv_id or
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None
try:
@@ -65,9 +90,35 @@ def answer_quote(message, conv_id):
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if intent['country']=='other' and re.search(r'美森|\bEXX\b',message,re.I) and not US.search(message):
return {'t':'您前面咨询的是其他目的地,美森和EXX在当前资料中是美国渠道。请确认这票是否发往美国,再继续核对时效和价格。'}
if intent['country'] is None and intent.get('mode')=='recommend':
return {'t':'请提供目的国家、货物重量和目的仓码或邮编,我再比较时效优先与运费优先的方案。'}
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','fastest_cheapest'):
return {'t':'请明确要比较的方案,可直接问“最快和最慢相差多少”,我按当前货物条件核算差额。'}
if intent['selection']!='fastest_cheapest':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
@@ -82,11 +133,18 @@ def answer_quote(message, conv_id):
dimensions += [piece['dims'] for piece in intent.get('packages') or [] if piece.get('dims')]
if any(not 0 < value <= 100000 for dims in dimensions for value in dims):
return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出,空运承运条件也需单独确认。'}
from .quote_answers import answer_business
preparation=None
if intent.get('mode')=='preparation':
from .shipping_faq import answer_preparation
preparation=answer_preparation(intent,book)
if preparation_topic(message)!='schedule' or not re.search(r'\d+\s*(?:KG|公斤|千克|克|吨|方|CBM)',intent['text'],re.I):
return {'t':preparation}
intent['mode']='cheapest' if re.search(r'最便宜|最划算|最低价',message) else 'quote'
business=answer_business(intent,book)
if business is not None:return {'t':business}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'}
destinations = variants(intent,book)
fees = []
for destination in destinations:
@@ -94,6 +152,14 @@ def answer_quote(message, conv_id):
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
return {'t':render_rates(intent,book,destinations,fees)}
reply=render_rates(intent,book,destinations,fees)
if timing_with_claim(message):
from .quote_answers import timing_liability
reply+='\n'+timing_liability(intent,book,destinations)
if intent.get('mode')=='total':
reply+='\n以上为已提供条件下可计算的运费,各方案按所列包税/不包税方式分别核算;目的仓、箱规及未确定附加费需核实后才能确认最终应付总价。'
if intent.get('mode')=='cheapest' and re.search(r'便宜一?点|折扣|优惠|打折',message):
reply+='\n以上按当前价表比较,折扣或合作价格需人工确认,尚未修改单价。'
return {'t':reply+('\n'+preparation if preparation else '')}
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'}
+57
View File
@@ -0,0 +1,57 @@
"""Pre-shipment answers from the active US book, without live operational claims."""
import re
def preparation_topic(text):
if re.search(r'理赔|赔偿|索赔|货损|丢件|破损',text):return None
if re.search(r'截仓|截单|船期|排仓|哪天开船|(?:什么时候|几号).*开船|能.*开船|'
r'(?:今天|明天|这周|下周).*(?:发货|发走|开船)|赶得上|来得及|(?:保证|必须|月底).*到',text):return 'schedule'
if re.search(r'上门.*(?:提货|取货|收货|揽收)|提货服务|交仓地址|仓库地址|仓.*(?:地址|电话|联系人|几点)|送哪个仓|仓库在哪|收货时间|\bpick\s*up\b|\bpickup\b',text,re.I):return 'delivery'
if re.search(r'报关资料|清关资料|报关.*(?:准备|材料)|申报比例|低申报|怎么申报|如何申报|认证资料|熏蒸',text):return 'documents'
if re.search(r'查验.*(?:费|承担)|谁.*承担.*查验',text):return 'inspection'
if re.search(r'(?:东|西|中)[12](?:区)?.*(?:区别|分区|哪区)|分区.*(?:什么意思|怎么分)',text):return 'zones'
if '一件代发' not in text and re.search(r'贴标|FBA标|标签|唛头|产地标|外箱.*贴|贴.*条码|made\s+in\s+china',text,re.I):return 'labels'
if re.search(r'哪些渠道|哪些线路|什么渠道|能发哪些仓|支持.*仓|(?:能发|能走|可以发).*美国',text):return 'coverage'
return None
def answer_preparation(intent,book):
topic=preparation_topic(intent['text'])
if topic is None:return None
from .quote_fees import document
if topic=='coverage':
services=list(dict.fromkeys(rate['service'] for rate in book['rates']))
return '美国可查询的线路:'+ '、'.join(services)+'。请提供目的仓码或邮编、重量及交货仓,我再匹配具体可用线路。'
if topic=='zones':
return '东1、东2等是报价资料中的不同目的地区域,需要按目的仓码或邮编匹配;同一仓码存在多个分区记录时会分别列出。请提供目的仓码或邮编,我按现有记录核对。'
if topic=='schedule':
return '请提供计划交仓日期、交货仓和目的仓码。具体截仓、装柜和开船安排需联系人工核对;当前参考时效不能作为月底或指定日期必达的承诺。'
if topic=='delivery':
raw=document(book,('不提供上门提货服务',))
if intent.get('english'):
return ('The current US route sheet requires delivery to the designated warehouse and does not offer pickup. ' if raw else '')+'Please confirm the route and Yiwu or South China origin with customer support before delivery; warehouse address, contact and receiving hours need confirmation.'
lines=[]
if raw:lines.append('当前美国渠道资料要求客户自行送货至对应仓库,不提供上门提货服务。')
lines.append('请确认所选渠道及义乌或华南交仓,仓库地址、联系人和收货时间请联系人工核对后再送仓。')
return '\n'.join(lines)
if topic=='labels':
label=document(book,('外箱','入仓条码','唛头'))
origin=document(book,('产品上和包装上','产地标识'))
lines=[]
if label:lines.append(re.sub(r'^\s*\d+[、..]\s*','',label.strip())+'\n代贴标签具体费用需按所选服务确认。')
if origin:lines.append('产品和包装还需有“Made in China”产地标识。')
return '\n'.join(lines) or '请提供渠道和目的仓码,由人工核对外箱标签及代贴要求。'
if topic=='documents':
if '熏蒸' in intent['text']:
raw=document(book,('熏蒸','备货阶段'))
return (raw.strip()+'\n' if raw else '')+'请确认是木制成品还是木质包装,并提供具体品名及渠道,核对是否需要熏蒸及证明材料后再安排发货。'
if re.search(r'申报比例|低申报|怎么申报|如何申报',intent['text']):
return '请提供品名、材质、用途、实际交易金额及销售链接,由业务核对申报资料。申报价需结合实际交易信息确认,不能按统一比例直接代算。'
raw=document(book,('单独报关需提供资料',))
if raw:return raw.strip()+'\n请补充具体品名及渠道,涉及商检或认证的资料需逐项核对。'
return '请提供具体品名和渠道,由人工核对装箱单、发票及对应报关、认证材料。'
raw=document(book,('查验','不包税货物','比例均摊'))
if raw:return raw.strip()+'\n具体金额按实际查验费用核算;包税渠道的承担方式需另行确认,不能直接套用不包税条款。'
return '查验费用和承担方式需按渠道、税费方式及实际费用确认,当前无法给出固定金额。'
+31 -2
View File
@@ -97,7 +97,27 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recent-item:hover { color: var(--brand-text); }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.composer { position: relative; flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.voice-feedback {
position: absolute;
z-index: 36;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 9px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
text-align: center;
font-size: 13px;
line-height: 1.5;
pointer-events: none;
}
.voice-feedback.is-error { border-color: var(--danger); color: var(--danger); }
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
.voice-toggle, .voice-hold { display: none; }
@@ -182,7 +202,16 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
.to-bottom.show { display: block; }
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
.typing::after { content: " ···"; color: var(--brand-text); }
.robot-message.typing { min-width: min(240px, 94%); }
.reply-waiting::before { content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 9px; border: 2px solid var(--border); border-top-color: var(--brand-text); border-radius: 50%; vertical-align: -2px; animation: reply-spin .8s linear infinite; }
.reply-waiting::after { content: "…"; color: var(--brand-text); animation: reply-pulse 1s ease-in-out infinite; }
.reply-ready { animation: reply-enter .18s ease-out; }
@keyframes reply-pulse { 50% { opacity: .3; } }
@keyframes reply-spin { to { transform: rotate(360deg); } }
@keyframes reply-enter { from { opacity: .4; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.reply-waiting::after, .reply-waiting::before, .reply-ready { animation: none; }
}
.sidebar-backdrop { display: none; }
.attachment-preview { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); color: var(--text); }
.attachment-preview img { height: 42px; width: 42px; object-fit: cover; border: 1px solid var(--border); border-radius: 7px; }
+91 -45
View File
@@ -6,6 +6,8 @@ const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
const composerStatus = document.getElementById("composer-status");
const voiceFeedback = document.getElementById('voice-feedback');
let voiceFeedbackTimer = null;
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
let draftTimer = null;
let draftSnapshot = null;
@@ -65,6 +67,21 @@ function setComposerStatus(message, isError){
composerStatus.classList.toggle("is-error", Boolean(isError));
}
function setVoiceStatus(message,isError){
if(voiceFeedbackTimer!==null) clearTimeout(voiceFeedbackTimer);
voiceFeedbackTimer=null;
if(voiceFeedback.textContent!==message) voiceFeedback.textContent=message || '';
voiceFeedback.hidden=!message;
voiceFeedback.classList.toggle('is-error',Boolean(isError));
if(message){
voiceFeedbackTimer=setTimeout(function(){
voiceFeedback.hidden=true;
voiceFeedback.textContent='';
voiceFeedbackTimer=null;
},4000);
}
}
async function requestJson(path, options){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
@@ -249,7 +266,7 @@ const I18N = {
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
emptyHistory: "暂无历史对话",
thinking: "正在思考中...",
thinking: "正在处理您的问题",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
@@ -290,11 +307,12 @@ const I18N = {
retryAIOnly: "请先返回 AI 问答,再重答这条消息。",
clickRetry: "点击重试",
welcomeTitle: "物流问题,随时为您解答",
welcomeEyebrow: "中欧 · 英国物流服务",
welcomeEyebrow: "中美物流服务",
serviceTag: "物流咨询服务",
footerTrack: "查入仓码",
footerQuote: "专线报价",
footerException: "异常处理",
footerQuote: "美国报价",
footerException: "异常订单",
exceptionQuery: "我有多少个异常订单?",
footerHuman: "联系人工",
inputShort: "输入入仓码…",
uploading: "附件正在上传,请稍候…",
@@ -339,10 +357,10 @@ const I18N = {
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
"按入仓码查轨迹", "美国海运专线报价", "查询异常订单", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
"如何查询物流轨迹?", "美国海运时效是多久?", "我有多少个异常订单?", "各个轨迹节点分别有多少订单?"
]
},
en: {
@@ -365,7 +383,7 @@ const I18N = {
confirmDel: "Delete this conversation?",
emptySearch: "No matching conversations",
emptyHistory: "No conversations yet",
thinking: "Thinking...",
thinking: "Preparing your reply",
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
@@ -406,11 +424,12 @@ const I18N = {
retryAIOnly: "Return to AI chat before regenerating this answer.",
clickRetry: "Retry",
welcomeTitle: "Your next shipment, made clearer.",
welcomeEyebrow: "EUROPE & UK LOGISTICS",
welcomeEyebrow: "CHINA–US LOGISTICS",
serviceTag: "Logistics support",
footerTrack: "Inbound lookup",
footerQuote: "Get a quote",
footerException: "Parcel issue",
footerQuote: "US quote",
footerException: "Exception orders",
exceptionQuery: "How many exception orders do I have?",
footerHuman: "Human support",
inputShort: "Inbound code…",
uploading: "Uploading your attachment…",
@@ -457,11 +476,11 @@ const I18N = {
langTip: "Switch language (Ctrl+L)",
toBottom: "Back to bottom",
quick: [
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
"Track by inbound code", "US sea freight quote", "View exception orders", "Talk to a human agent"
],
hotList: [
"How do I track my shipment?", "How long does Europe sea freight take?",
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
"How do I track my shipment?", "How long does US sea freight take?",
"How many exception orders do I have?", "How many orders are at each tracking stage?"
]
}
};
@@ -633,7 +652,8 @@ function welcomeHTML(){
const h = t("hotList");
const kinds = ["track", "quote", "issue", "human"];
const cards = q.map(function(question, index){
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`;
const query = index === 2 ? t('exceptionQuery') : question;
const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(query)}"`;
return `<button type="button" class="suggest-item" ${action}><span class="suggest-icon">${welcomeIcon(kinds[index])}</span><span class="suggest-copy"><strong>${question}</strong></span><span class="suggest-arrow" aria-hidden="true">↗</span></button>`;
}).join("");
return `
@@ -873,6 +893,7 @@ function ensureConv(){
}
function resetConversation(id){
setVoiceStatus('');
flushDraft();
emojiPicker.close();
if(orderLookup) orderLookup.cancel();
@@ -1685,13 +1706,30 @@ async function doSend(retryText, showQuestion){
}
const aiBubble = addMsg(t("thinking"), false);
aiBubble.classList.add("typing");
aiBubble.textContent = t('thinking');
aiBubble.classList.add("typing", "reply-waiting");
aiBubble.setAttribute('aria-busy', 'true');
activeBubble = aiBubble;
setBusy(true);
let es = null;
let firstChunk = true;
let receivedDone = false;
let receivedContent = false;
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
const reply = ChatReply.create({
shouldAnimate:function(){
return !document.hidden && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
},
onText:function(raw){
if(settled || version !== conversationVersion) return;
aiBubble.classList.remove('reply-waiting');
aiBubble.dataset.raw = raw;
aiBubble.innerHTML = parseBold(raw);
scrollChatToEnd();
},
onComplete:finish,
onError:function(){ fail(t('networkFail')); },
});
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 90000);
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
@@ -1708,11 +1746,13 @@ async function doSend(retryText, showQuestion){
}
function finish(){
if(settled) return;
if(settled || version !== conversationVersion) return;
settled = true;
reply.cancel();
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
aiBubble.classList.remove("typing", "reply-waiting");
aiBubble.setAttribute('aria-busy', 'false');
if(activeBubble === aiBubble) activeBubble = null;
let raw = aiBubble.dataset.raw || "";
@@ -1734,9 +1774,13 @@ async function doSend(retryText, showQuestion){
function fail(text){
if(settled) return;
try{ reply.flush(); }catch(error){}
settled = true;
reply.cancel();
cleanup();
setBusy(false);
aiBubble.classList.remove('reply-waiting');
aiBubble.setAttribute('aria-busy', 'false');
if(activeBubble === aiBubble) activeBubble = null;
renderError(aiBubble, text || t("busyRetry"), function(){
return retryAnswer(aiBubble, fullContent);
@@ -1747,9 +1791,11 @@ async function doSend(retryText, showQuestion){
function stop(){
if(settled) return;
settled = true;
reply.cancel();
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
aiBubble.classList.remove("typing", "reply-waiting");
aiBubble.setAttribute('aria-busy', 'false');
if(activeBubble === aiBubble) activeBubble = null;
const raw = aiBubble.dataset.raw || "";
if(raw){
@@ -1767,7 +1813,7 @@ async function doSend(retryText, showQuestion){
cancelActive = stop;
function handleFrame(dataText){
if(settled || version !== conversationVersion) return;
if(settled || receivedDone || version !== conversationVersion) return;
let obj;
try{
obj = JSON.parse(dataText);
@@ -1778,38 +1824,37 @@ async function doSend(retryText, showQuestion){
if(obj.e){ fail(obj.e); return; }
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
if(obj.card){
receivedContent = true;
clearTimeout(firstTimer);
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
firstChunk = false;
scrollChatToEnd();
reply.card(function(){
if(settled || version !== conversationVersion) return;
aiBubble.classList.remove('reply-waiting');
aiBubble.classList.add('track-card', 'reply-ready');
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
scrollChatToEnd();
});
return;
}
if(obj.replace){
receivedContent = true;
clearTimeout(firstTimer);
aiBubble.dataset.raw = obj.replace;
aiBubble.innerHTML = parseBold(obj.replace);
scrollChatToEnd();
reply.replace(obj.replace);
return;
}
if(obj.suggest){
aiBubble.dataset.suggest = JSON.stringify(obj.suggest);
return;
}
if(obj.d){ finish(); return; }
if(typeof obj.t !== "string") return;
clearTimeout(firstTimer);
if(firstChunk){
aiBubble.innerHTML = "";
aiBubble.dataset.raw = "";
firstChunk = false;
if(obj.d){
if(!receivedContent){ fail(t('networkFail')); return; }
receivedDone = true; cleanup(); reply.complete(); return;
}
const raw = (aiBubble.dataset.raw || "") + obj.t;
aiBubble.dataset.raw = raw;
aiBubble.innerHTML = parseBold(raw);
scrollChatToEnd();
if(typeof obj.t !== "string" || !obj.t) return;
receivedContent = true;
clearTimeout(firstTimer);
reply.append(obj.t);
}
// 用 POST + fetch 流式读取:问题放在请求体里,不受 URL 长度限制
@@ -1856,18 +1901,18 @@ async function doSend(retryText, showQuestion){
if(r.done){
buffer += decoder.decode();
drain(true);
if(!settled) fail(t("networkFail"));
if(!settled && !receivedDone) fail(t("networkFail"));
return;
}
buffer += decoder.decode(r.value, {stream: true});
drain(false);
if(!settled) return pump();
if(!settled && !receivedDone) return pump();
});
}
return pump();
}).catch(function(err){
if(err && err.name === "AbortError") return; // 用户主动停止
if(settled) return;
if(settled || receivedDone) return;
fail(t("networkFail"));
});
return true;
@@ -2036,7 +2081,7 @@ voiceInput = ChatVoice.create({
toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'),
panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'),
clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom,
text:t, status:setComposerStatus, language:() => LANG,
text:t, status:setVoiceStatus, language:() => LANG,
context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid,
canStart:function(){
if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait');
@@ -2084,7 +2129,8 @@ document.querySelectorAll('[data-question-index]').forEach(function(button){
button.addEventListener('click', function(){
const index=Number(button.dataset.questionIndex);
if(index === 0) return showOrderLookup(undefined,undefined,button);
return window.askNewTopic(t('quick')[index]);
const question = index === 2 ? t('exceptionQuery') : t('quick')[index];
return window.askNewTopic(question);
});
});
chatDom.addEventListener('click', function(event){
+81
View File
@@ -0,0 +1,81 @@
// Pace already received text while preserving network completion and cancellation.
var ChatReply = (function(){
function create(options){
const now = options.now || Date.now;
const readyAt = now() + 300;
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
? new Intl.Segmenter(undefined, {granularity:'grapheme'}) : null;
let text = '', characters = [], visible = 0;
let startedAt = null, timer = null, card = null;
let complete = false, closed = false;
function clearTimer(){
if(timer !== null) clearTimeout(timer);
timer = null;
}
function schedule(delay){
if(timer === null && !closed) timer = setTimeout(function(){
try{ draw(false); }
catch(error){ closed = true; clearTimer(); options.onError(error); }
}, delay);
}
function draw(force){
clearTimer();
if(closed) return;
const animate = !force && options.shouldAnimate();
if(animate && now() < readyAt){ schedule(readyAt - now()); return; }
if(card){
const show = card;
card = null;
show();
}
if(visible < characters.length){
if(startedAt === null) startedAt = now();
const elapsed = Math.max(0, now() - startedAt);
const rate = Math.max(45, characters.length / 4);
visible = animate ? Math.min(characters.length, Math.max(visible + 1, Math.floor(elapsed * rate / 1000))) : characters.length;
options.onText(characters.slice(0, visible).join(''));
}
if(visible < characters.length){ schedule(16); return; }
if(complete){
closed = true;
options.onComplete();
}
}
function update(){
if(closed) return;
if(!options.shouldAnimate()) draw(true);
else schedule(Math.max(0, readyAt - now()));
}
function setText(value){
text = value;
characters = segmenter ? Array.from(segmenter.segment(text), item => item.segment) : Array.from(text);
}
return {
append:function(value){
if(closed) return;
card = null;
setText(text + value);
update();
},
replace:function(value){
if(closed) return;
card = null;
setText(value);
visible = 0;
update();
},
card:function(show){
if(closed) return;
setText('');
visible = 0;
card = show;
update();
},
complete:function(){ complete = true; update(); },
flush:function(){ draw(true); },
cancel:function(){ closed = true; clearTimer(); },
};
}
return {create:create};
})();
+23 -9
View File
@@ -4,6 +4,7 @@ var ChatVoice = {
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
let session = null, mode = false;
let pressedId = null;
const active = current => session === current;
function stopTracks(current){
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
@@ -39,6 +40,7 @@ var ChatVoice = {
}
function setMode(on){
cancel();
pressedId=null;
mode = on;
render();
if(on && input.blur) input.blur();
@@ -172,24 +174,30 @@ var ChatVoice = {
};
if(window.PointerEvent){
hold.addEventListener('pointerdown', function(event){
if(event.button !== 0) return;
if(event.button !== 0 || pressedId!==null) return;
event.preventDefault();
pressedId=event.pointerId;
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
start(event.pointerId, event.clientY);
});
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
hold.addEventListener('pointerup', function(event){
event.preventDefault();
if(pressedId===event.pointerId) pressedId=null;
if(session && session.id === event.pointerId) finish(session, false);
});
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
pressedId=null;
if(session && session.holding) finish(session, true);
}));
}else{
hold.addEventListener('touchstart', function(event){
event.preventDefault();
if(event.touches.length !== 1) return;
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
if(pressedId!==null) return;
const touch=event.touches[0];
pressedId=touch.identifier;
start(touch.identifier, touch.clientY);
}, {passive:false});
hold.addEventListener('touchmove', function(event){
event.preventDefault();
@@ -197,23 +205,29 @@ var ChatVoice = {
}, {passive:false});
hold.addEventListener('touchend', function(event){
event.preventDefault();
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
for(const touch of event.changedTouches){
if(pressedId===touch.identifier) pressedId=null;
if(session && session.id === touch.identifier) finish(session, false);
}
}, {passive:false});
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
hold.addEventListener('touchcancel', function(){ pressedId=null;if(session) finish(session, true); });
}
hold.addEventListener('contextmenu', event => event.preventDefault());
hold.addEventListener('keydown', function(event){
if(![' ','Enter'].includes(event.key) || event.repeat) return;
if(![' ','Enter'].includes(event.key) || event.repeat || pressedId!==null) return;
pressedId='keyboard';
event.preventDefault(); start('keyboard', 0);
});
hold.addEventListener('keyup', function(event){
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
event.preventDefault(); finish(session, false);
if([' ','Enter'].includes(event.key)){
if(pressedId==='keyboard') pressedId=null;
event.preventDefault();
if(session && session.id === 'keyboard') finish(session, false);
}
});
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
window.addEventListener('pagehide', function(){ cancel(); });
document.addEventListener('visibilitychange', function(){ if(document.hidden){pressedId=null;cancel();} });
window.addEventListener('pagehide', function(){ pressedId=null;cancel(); });
render();
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
}
+5 -3
View File
@@ -42,7 +42,7 @@
<section class="ai-welcome-card">
<div class="ai-welcome-top">
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
<div class="welcome-eyebrow">中美物流服务</div>
<h3>物流问题,随时为您解答</h3>
</div>
</section>
@@ -51,8 +51,8 @@
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="composer-toolbar"><div class="hot-bar">
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">美国报价</button>
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常订单</button>
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
<div class="input-area">
@@ -64,6 +64,7 @@
<button type="button" id="send-button">发送</button>
</div>
<p id="composer-status" class="composer-status" role="status" hidden></p>
<div id="voice-feedback" class="voice-feedback" role="status" aria-live="polite" hidden></div>
</footer>
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
@@ -102,6 +103,7 @@
<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>
<script src="{{ url_for('static', filename='js/reply.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+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, '')
}
})
+188 -15
View File
@@ -15,7 +15,7 @@ async function customerPage(pageOptions = {}){
get length(){return Object.keys(values).length;}
};}
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
let intervalId = 0, timerId = 0, nextConvId = 1, clock = 0;
let voiceOptions;
class Element {
constructor(){
@@ -24,12 +24,12 @@ async function customerPage(pageOptions = {}){
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
add: (...names) => { this.className += ' '+names.join(' '); },
remove: (...names) => { this.className = this.className.split(' ').filter(value => !names.includes(value)).join(' '); },
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
};
}
set innerHTML(value){ this.html = value; this.children = []; }
set innerHTML(value){ this.text = ''; this.html = value; this.children = []; }
get innerHTML(){ return this.html || ''; }
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
@@ -74,8 +74,12 @@ async function customerPage(pageOptions = {}){
execCommand: () => pageOptions.copyResult !== false,
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
class TestDate extends Date {
constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); }
static now(){ return pageOptions.reducedMotion === false ? clock : Date.now(); }
}
let state = {status:'active', messages:[]};
let customerState=pageOptions.customer || null;
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
@@ -83,17 +87,25 @@ async function customerPage(pageOptions = {}){
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
matchMedia:query => ({matches:query.includes('prefers-reduced-motion') ? pageOptions.reducedMotion !== false
: context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id),
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay,at:clock+delay}); return id; },
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(hooks[url]){
const result=await hooks[url](options);
if(url==='/api/customer/login' || url==='/api/customer/logout'){
const data=await result.json();
if(result.ok && data.ok) customerState=url==='/api/customer/login' ? data.user : null;
}
return result;
}
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
@@ -107,7 +119,7 @@ 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(pageOptions.customer),user:pageOptions.customer || null}
: url === '/api/customer/session' ? {ok:true,authenticated:Boolean(customerState),user:customerState}
: 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}
@@ -115,7 +127,7 @@ async function customerPage(pageOptions = {}){
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'visitor.js', 'customer.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', 'reply.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;
@@ -148,6 +160,17 @@ async function customerPage(pageOptions = {}){
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
setState:value => { state=value; },
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
advanceTimers:async milliseconds => {
const end=clock+milliseconds;
let count=0;
while(true){
const next=Array.from(timers).filter(([,timer])=>timer.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];
if(!next) break;
assert.ok(++count<10000,'Timer queue must settle');
clock=next[1].at;timers.delete(next[0]);next[1].callback();await flush();
}
clock=end;await flush();
},
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
@@ -193,7 +216,53 @@ test('switching to a quick topic discards the previous topic attachment', async
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '美国海运专线报价');
});
test('the homepage and exception shortcut match the US service in both languages', async () => {
for(const language of ['zh','en']){
const app=await customerPage({human:false,storage:{'wuliu-lang':language}});
await app.click(app.element('.new-chat-btn'));
const html=app.element('chatDom').innerHTML;
assert.doesNotMatch(html,/欧洲|中欧|英国物流|EUROPE|Europe|理赔|file a claim/);
assert.match(html,language==='zh'?/中美物流服务/:/CHINA–US LOGISTICS/);
assert.match(html,language==='zh'?/美国海运专线报价/:/US sea freight quote/);
assert.match(html,language==='zh'?/查询异常订单/:/View exception orders/);
const question=language==='zh'?'我有多少个异常订单?':'How many exception orders do I have?';
assert.ok(html.includes('data-question="'+question+'"'));
await app.click(app.quickButtons[2]);
assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,question);
}
});
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'}});
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.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 () => {
const app=await customerPage({human:false,width:390});
const status=app.element('composer-status');
app.voiceOptions.status('语音输入失败,请重试。',true);
const feedback=app.element('voice-feedback');
assert.equal(feedback.hidden,false);
assert.equal(feedback.textContent,'语音输入失败,请重试。');
assert.ok(feedback.classList.contains('is-error'));
assert.equal(status.textContent,'');
app.voiceOptions.status('语音输入失败,请重试。',true);
assert.equal(feedback.hidden,false);
await app.runTimers(4000);
assert.equal(feedback.hidden,true);
app.voiceOptions.status('语音输入失败,请重试。',true);
await app.click(app.element('.new-chat-btn'));
assert.equal(feedback.hidden,true);
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
@@ -227,6 +296,110 @@ test('rate limit responses show the server message and allow retry', async () =>
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
test('a local completed reply keeps waiting feedback then progressively renders before enabling actions', async () => {
const app=await customerPage({human:false,reducedMotion:false});
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.match(bubble.textContent,/正在处理/);
assert.equal(app.context.window.__busy,true);
assert.equal(bubble.querySelector('.msg-actions'),null);
await app.advanceTimers(299);
assert.match(bubble.textContent,/正在处理/);
await app.advanceTimers(1);
assert.ok(bubble.dataset.raw.length>0 && bubble.dataset.raw.length<'测试 AI 回复'.length);
assert.equal(app.context.window.__busy,true);
await app.advanceTimers(2000);
assert.equal(bubble.dataset.raw,'测试 AI 回复');
assert.equal(app.context.window.__busy,false);
assert.ok(bubble.querySelector('.msg-actions'));
assert.equal(bubble.getAttribute('aria-busy'),'false');
assert.equal(bubble.querySelector('.retry-btn'),null);
});
test('stopping during the waiting stage cancels pending presentation', async () => {
const app=await customerPage({human:false,reducedMotion:false});
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
await app.click(app.element('send-button'));
const stopped=bubble.textContent;
await app.advanceTimers(10000);
assert.equal(bubble.textContent,stopped);
assert.equal(bubble.dataset.raw,undefined);
assert.equal(app.context.window.__busy,false);
});
test('stopping mid-reveal freezes the displayed prefix and preserves the next draft', async () => {
const app=await customerPage({human:false,reducedMotion:false});
const answer='完整的报价内容。'.repeat(100);
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({t:answer})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
await app.advanceTimers(600);
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
const prefix=bubble.dataset.raw;
assert.ok(prefix.length<answer.length);
app.element('input-text').value='下一条问题';
await app.click(app.element('send-button'));
await app.advanceTimers(10000);
assert.equal(bubble.dataset.raw,prefix);
assert.equal(app.element('input-text').value,'下一条问题');
assert.equal(app.context.window.__busy,false);
});
test('switching conversations cancels a response whose network stream already completed', async () => {
const app=await customerPage({human:false,reducedMotion:false});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"旧对话的排队回复"}\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
await app.click(app.element('.new-chat-btn'));
await app.advanceTimers(10000);
assert.doesNotMatch(app.element('chatDom').textContent,/旧对话的排队回复/);
assert.equal(app.context.window.__busy,false);
});
test('premature stream closure flushes received text and cancels its animation before retry', async () => {
const app=await customerPage({human:false,reducedMotion:false});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.match(bubble.textContent,/已经收到的部分回答/);
assert.ok(bubble.querySelector('.retry-btn'));
await app.advanceTimers(10000);
assert.equal(app.context.window.__busy,false);
assert.equal(bubble.dataset.raw,'已经收到的部分回答');
});
test('an empty completion releases the waiting indicator and offers retry', async () => {
const app=await customerPage({human:false,reducedMotion:false});
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":""}\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('查询报价');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.ok(bubble.querySelector('.retry-btn'));
assert.equal(bubble.classList.contains('reply-waiting'),false);
assert.equal(app.context.window.__busy,false);
await app.advanceTimers(10000);
assert.equal(app.context.window.__busy,false);
});
test('a structured order card is shown after waiting and completes without a false disconnect', async () => {
const app=await customerPage({human:false,reducedMotion:false});
const card={kind:'order',no:'ORDER12345',status:'履约中',fields:[],nodes:[],summary:'入仓码 IN12345'};
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
await app.context.window.fillInput('IN12345');
await flush();
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
assert.match(bubble.textContent,/正在处理/);
await app.advanceTimers(300);
assert.ok(bubble.classList.contains('track-card'));
assert.equal(bubble.dataset.raw,card.summary);
assert.equal(app.context.window.__busy,false);
assert.equal(bubble.querySelector('.retry-btn'),null);
});
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
const app=await customerPage({human:false});
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
@@ -390,7 +563,7 @@ test('tracking does not interrupt human support while quote and exception shortc
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 2);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2);
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']);
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '我有多少个异常订单?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
@@ -398,14 +571,14 @@ test('tracking does not interrupt human support while quote and exception shortc
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
for(const question of ['美国海运专线报价', '各个轨迹节点分别有多少订单?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '各个轨迹节点分别有多少订单?']);
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
});
@@ -464,7 +637,7 @@ test('a late conversation creation cannot replace the newest quick question', as
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(requests[0].body.msg, '我有多少个异常订单?');
assert.equal(app.context.window.__busy, false);
});
+174
View File
@@ -0,0 +1,174 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function playback(options = {}){
let clock = 0, nextId = 0, animate = options.animate !== false, completed = 0;
const timers = new Map(), text = [], errors = [];
const context = vm.createContext({
Intl,
setTimeout(callback, delay){ const id = ++nextId; timers.set(id, {callback, at:clock + delay}); return id; },
clearTimeout(id){ timers.delete(id); },
});
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js/reply.js'), 'utf8'), context);
const reply = context.ChatReply.create({
now:() => clock,
shouldAnimate:() => animate,
onText:value => text.push(value),
onComplete:() => { completed++; },
onError:error => errors.push(error),
});
function advance(milliseconds){
const end = clock + milliseconds;
let count = 0;
while(true){
const next = Array.from(timers).filter(([,timer]) => timer.at <= end).sort((a,b) => a[1].at - b[1].at)[0];
if(!next) break;
assert.ok(++count < 10000, 'Timer queue must settle');
clock = next[1].at;
timers.delete(next[0]);
next[1].callback();
}
clock = end;
}
return {reply, advance, text, errors, timers, completed:() => completed, reduceMotion:() => { animate = false; }};
}
test('a complete local response waits briefly and reveals text before finishing', () => {
const app = playback();
const answer = '这是来自数据库的报价回复。'.repeat(10);
app.reply.append(answer);
app.reply.complete();
app.advance(299);
assert.deepEqual(app.text, []);
app.advance(1);
assert.ok(app.text.at(-1).length < answer.length);
assert.equal(app.completed(), 0);
app.advance(4200);
assert.equal(app.text.at(-1), answer);
assert.equal(app.completed(), 1);
assert.equal(app.timers.size, 0);
});
test('real streaming chunks keep their full content and finish once', () => {
const app = playback();
app.advance(1000);
app.reply.append('第一段');
app.advance(200);
app.reply.append(',接着第二段。');
app.reply.complete();
app.advance(4500);
assert.equal(app.text.at(-1), '第一段,接着第二段。');
assert.equal(app.completed(), 1);
app.reply.complete();
app.advance(100);
assert.equal(app.completed(), 1);
});
test('replacement supersedes both pending text and a previously displayed prefix', () => {
const app = playback();
app.reply.append('旧的详细报价'.repeat(20));
app.advance(400);
assert.ok(app.text.length);
app.reply.replace('新的简明报价');
app.reply.complete();
app.advance(4500);
assert.equal(app.text.at(-1), '新的简明报价');
assert.equal(app.completed(), 1);
});
test('cancellation before the first reveal prevents late text and completion', () => {
const app = playback();
app.reply.append('等待中的回复');
app.reply.complete();
app.reply.cancel();
app.advance(10000);
assert.deepEqual(app.text, []);
assert.equal(app.completed(), 0);
assert.equal(app.timers.size, 0);
});
test('cancellation freezes a displayed prefix and ignores later chunks', () => {
const app = playback();
app.reply.append('已经收到的长回复'.repeat(100));
app.advance(500);
const prefix = app.text.at(-1);
app.reply.cancel();
app.reply.append('迟到的内容');
app.reply.complete();
app.advance(10000);
assert.equal(app.text.at(-1), prefix);
assert.equal(app.completed(), 0);
});
test('cards are shown intact after the waiting feedback', () => {
const app = playback();
let shown = 0;
app.reply.card(() => { shown++; });
app.reply.complete();
app.advance(299);
assert.equal(shown, 0);
app.advance(1);
assert.equal(shown, 1);
assert.equal(app.completed(), 1);
});
test('reduced motion immediately renders text and cards', () => {
const app = playback({animate:false});
app.reply.append('快速显示的完整回复');
assert.equal(app.text.at(-1), '快速显示的完整回复');
app.reply.complete();
assert.equal(app.completed(), 1);
assert.equal(app.timers.size, 0);
const card = playback({animate:false});
let shown = false;
card.reply.card(() => { shown = true; });
card.reply.complete();
assert.equal(shown, true);
assert.equal(card.completed(), 1);
});
test('a long answer has a bounded presentation duration', () => {
const app = playback();
const answer = '运费和时效。'.repeat(4000);
app.reply.append(answer);
app.reply.complete();
app.advance(4320);
assert.equal(app.text.at(-1), answer);
assert.equal(app.completed(), 1);
});
test('grapheme clusters remain intact during progressive display', () => {
const app = playback();
app.reply.append('👨‍👩‍👧‍👦您好,这是完整字符。');
app.advance(300);
assert.equal(app.text[0], '👨‍👩‍👧‍👦');
app.reply.complete();
app.advance(4500);
assert.equal(app.text.at(-1), '👨‍👩‍👧‍👦您好,这是完整字符。');
});
test('a hidden or reduced-motion page can flush all received text', () => {
const app = playback();
const answer = '完整的报价内容'.repeat(50);
app.reply.append(answer);
app.reply.complete();
app.advance(350);
app.reduceMotion();
app.reply.flush();
assert.equal(app.text.at(-1), answer);
assert.equal(app.completed(), 1);
assert.equal(app.timers.size, 0);
});
test('an asynchronous rendering failure cancels the queue and reports once', () => {
const app = playback();
app.reply.card(() => { throw new Error('Cannot render card'); });
app.reply.complete();
app.advance(500);
assert.equal(app.errors.length, 1);
assert.equal(app.completed(), 0);
assert.equal(app.timers.size, 0);
});
+20
View File
@@ -97,6 +97,26 @@ test('short presses and unavailable service do not upload recordings',async()=>{
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
});
test('a failed physical hold cannot restart until it has been released',async()=>{
for(const touch of [false,true]){
const app=voicePage({enabled:false,touch});
app.open();
const point={identifier:7,clientY:300};
const down=async()=>{
if(touch) app.elements.hold.listeners.touchstart({touches:[point],preventDefault(){}});
else app.elements.hold.listeners.pointerdown(app.event());
await flush();
};
await down();await down();
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,1);
if(touch) app.elements.hold.listeners.touchend({changedTouches:[point],preventDefault(){}});
else app.elements.hold.listeners.pointerup(app.event());
await down();
assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,2);
assert.equal(app.permissionCalls,0);
}
});
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
app.open();await app.down();await app.up();grant(app.stream);await flush();
+42 -1
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
@@ -349,6 +350,14 @@ class ApiTests(unittest.TestCase):
with self.client.get('/static/fa.jpg') as image:
self.assertEqual(image.status_code, 200)
def test_600_request_budget_accepts_the_last_request_and_blocks_the_next(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 600
now = time.time()
self.app.extensions['rate_hits']['127.0.0.1'] = [now] * 599
with patch('logistics_chat.hooks.time.time', return_value=now):
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
def test_attachment_roundtrip_and_rejection(self):
response = self.client.post('/api/upload', data={'file': (io.BytesIO(b'hello'), 'hello.txt')})
self.assertEqual(response.status_code, 200)
@@ -359,12 +368,44 @@ class ApiTests(unittest.TestCase):
def test_health_check_remains_available_when_business_requests_are_rate_limited(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
self.assertEqual(self.client.get('/').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
response = self.client.get('/healthz')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'status': 'ok'})
def test_homepage_does_not_consume_or_depend_on_business_rate_budget(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
for method in ('get', 'head'):
response = getattr(self.client, method)('/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/html')
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
for method in ('get', 'head'):
response = getattr(self.client, method)('/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/html')
def test_chat_and_supporting_requests_have_separate_limited_budgets(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
response = self.client.post('/stream_chat', json=[])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/event-stream')
self.assertEqual(self.client.post('/stream_chat', json=[]).status_code, 429)
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
self.assertEqual(self.client.get('/stream_chat').status_code, 429)
def test_supporting_request_limit_does_not_block_chat(self):
self.app.config['RATE_LIMIT_PER_MIN'] = 1
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
response = self.client.post('/stream_chat', json=[])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, 'text/event-stream')
self.assertEqual(self.client.post('/stream_chat', json=[]).status_code, 429)
def test_feedback_stats_and_csv(self):
cid = self.conversation()
with self.app.app_context():
+31
View File
@@ -21,6 +21,7 @@ class ConfigurationTests(unittest.TestCase):
environment.start()
self.addCleanup(environment.stop)
os.environ.pop('DEEPSEEK_API_KEY', None)
os.environ.pop('RATE_LIMIT_PER_MIN', None)
def write_env(self, text):
(self.root / '.env').write_text(text, encoding='utf-8')
@@ -98,6 +99,36 @@ class ConfigurationTests(unittest.TestCase):
os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment'
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment')
def test_rate_limit_defaults_to_600_requests_per_minute(self):
self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 600)
def test_rate_limit_can_be_configured_in_the_repository_env_file(self):
self.write_env('RATE_LIMIT_PER_MIN=" 1200 " # Local request budget\n')
self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 1200)
self.assertNotIn('RATE_LIMIT_PER_MIN', os.environ)
def test_rate_limit_environment_and_factory_overrides_take_priority(self):
self.write_env('RATE_LIMIT_PER_MIN=1200\n')
os.environ['RATE_LIMIT_PER_MIN'] = '600'
self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 600)
self.assertEqual(self.application(RATE_LIMIT_PER_MIN=900).config['RATE_LIMIT_PER_MIN'], 900)
def test_rate_limit_rejects_invalid_file_values(self):
for line in ('RATE_LIMIT_PER_MIN=', 'RATE_LIMIT_PER_MIN', 'RATE_LIMIT_PER_MIN=0',
'RATE_LIMIT_PER_MIN=-1', 'RATE_LIMIT_PER_MIN=600.5', 'RATE_LIMIT_PER_MIN=invalid'):
with self.subTest(line=line):
self.write_env(line + '\n')
with self.assertRaisesRegex(ValueError, 'RATE_LIMIT_PER_MIN must be a positive integer'):
self.application()
def test_rate_limit_rejects_invalid_environment_values_over_a_valid_file(self):
self.write_env('RATE_LIMIT_PER_MIN=600\n')
for value in ('', '0', '-1', '600.5', 'invalid'):
with self.subTest(value=value):
os.environ['RATE_LIMIT_PER_MIN'] = value
with self.assertRaisesRegex(ValueError, 'RATE_LIMIT_PER_MIN must be a positive integer'):
self.application()
if __name__ == '__main__':
unittest.main()
+254
View File
@@ -0,0 +1,254 @@
"""Oral-language, US-region isolation and scoped-page regressions using synthetic facts."""
from decimal import Decimal
from pathlib import Path
import re
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.order_data import import_records
from logistics_chat.pricing import detect_dim_unit,dims_to_cm
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_intent import parse_intent
from logistics_chat.quote_engine import quantity
from test_quotes import fixture_snapshot,frames
class CustomerGapTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
'address':'','source_sheet':'合成分区','source_cell':'A8'})
texts=['偏远地址加收4元/kg;超偏远地址加收6元/kg','铁铝制品另外加附加费¥5/KG',
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
'超重收+70/件','客户自行交货,不提供上门提货服务',
'禁止运输的货物:纯电池、液体','需熏蒸的货物由客户在备货阶段自行完成']
book['documents']=[{'sheet':'合成渠道','cell':f'A{i+1}','text':text} for i,text in enumerate(texts)]
self.order={'tenant_id':'qa-a','order_no':'FOCUS20260001','inbound_code':'QAFOCUS20260001',
'country_code':'US','status':'COMPLETED','package_count':2,
'tracking_json':[{'code':'IN_TRANSIT','actual_at':'2026-10-01 10:00'}]}
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True)
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'OTHER20260099','inbound_code':'QAOTHER20260099'}])
c.commit();c.close()
def conversation(self):return self.client.post('/api/new_chat').json['id']
def events(self,q,cid=None,client=None):
with patch('logistics_chat.ai_client.post_completion') as model:
result=frames((client or self.client).post('/stream_chat',json={'msg':q,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(result[-1],{'d':1},result)
return result
def ask(self,q,cid=None):return self.events(q,cid)[0]['t']
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def test_us_natural_questions_never_enter_old_regional_model_faqs(self):
cases=('你们这个报价是按毛重还是净重?','运费能便宜一点吗?','这个价格含税含派送吗?',
'一共多少钱,给我最终总价','这个物流服务范围具体怎么执行?')
for q in cases:
with self.subTest(q=q):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
text=self.ask(q,cid)
self.assertNotIn('21KG',text);self.assertNotIn('250',text);self.assertNotIn('7元/KG',text)
text=self.ask('我想发点货到美国,怎么收费?')
self.assertIn('元/KG',text);self.assertIn('多少公斤',text)
def test_final_total_respects_tax_labels_and_keeps_unconfirmed_fees_separate(self):
cid=self.conversation();self.ask('美国美西2箱每箱15KG,40x30x20cm,义乌普船海派',cid)
text=self.ask('一共多少钱,给我最终总价',cid)
self.assertIn('30KG = 60元',text);self.assertIn('· 包税',text)
self.assertNotIn('价格不含税',text);self.assertIn('最终应付总价',text)
self.assertIn('折扣或合作价格需人工确认',self.ask('运费能便宜一点吗',cid))
def test_negative_warehouse_and_origin_edits_do_not_keep_rejected_values(self):
cid=self.conversation();self.ask('美国30KG发ZZZ1,深圳普船海派',cid)
text=self.ask('不是发ZZZ1,是YYY2',cid)
self.assertNotIn('ZZZ1',text);self.assertIn('YYY2',text)
text=self.ask('不要深圳,还是义乌交仓',cid)
self.assertIn('· 义乌',text);self.assertNotIn('· 华南',text)
text=self.ask('50KG呢',cid)
self.assertIn('YYY2',text);self.assertNotIn('ZZZ1',text);self.assertIn('美国 50KG',text)
def test_inches_convert_before_charge_weight_and_acceptance_limits(self):
intent=parse_intent('美国30KG,60x40x30英寸,1箱普船海派',set())
self.assertEqual(intent['dim_unit'],'in')
self.assertEqual(dims_to_cm([60,40,30],'in'),[152.4,101.6,76.2])
self.assertGreater(quantity(intent,{'kind':'parcel','unit':'kg'}),Decimal('190'))
text=self.ask('美国美西30KG,60x40x30英寸,1箱普船海派,义乌')
self.assertIn('人工确认是否接收',text)
for text in ('60x40x30 inches','60x40x30in','60x40x30"'):
with self.subTest(text=text):
self.assertEqual(detect_dim_unit(text),'in')
self.assertIn('人工确认是否接收',self.ask('美国美西30KG,'+text+',1箱普船海派,义乌'))
self.assertEqual(detect_dim_unit('60x40x30,合计1立方米'),'cm')
self.assertEqual(detect_dim_unit('60x40x30,合计1m3'),'cm')
self.assertEqual(detect_dim_unit('1.2x0.8x0.6m呢'),'m')
def test_spoken_carton_and_dimensions_are_used_without_reasking(self):
intent=parse_intent('美国30KG一个箱子,60长40宽30高,怎么算?',set())
self.assertEqual(intent['boxes'],1);self.assertEqual(intent['dims'],[60,40,30])
text=self.ask('美国美西30KG一个箱子,60长40宽30高,义乌普船海派')
self.assertIn('最高项70元/件',text)
self.assertNotIn('请补充箱数',text)
def test_box_units_are_local_and_each_axis_can_carry_its_own_unit(self):
cases=(('12英寸显示器,包装60x40x30cm',[60,40,30],'cm',Decimal('30')),
('80cm×70cm×60cm',[80,70,60],'cm',Decimal('56')),
('80cmx70cmx60cm',[80,70,60],'cm',Decimal('56')),
('800mm*700mm*600mm',[800,700,600],'mm',Decimal('56')),
('长80厘米宽70厘米高60厘米',[80,70,60],'cm',Decimal('56')),
('0.8m×70cm×600mm',[80,70,60],'cm',Decimal('56')),
('12英寸显示器,包装60x40x30',[60,40,30],'cm',Decimal('30')))
for wording,dims,unit,charge in cases:
with self.subTest(wording=wording):
intent=parse_intent('美国美西30KG,'+wording+',1箱义乌普船海派',set())
self.assertEqual(intent['dims'],dims);self.assertEqual(intent['dim_unit'],unit)
self.assertEqual(quantity(intent,{'kind':'parcel','unit':'kg'}),charge)
text=self.ask('美国美西30KG,'+wording+',1箱义乌普船海派')
self.assertIn(f'{charge}KG =',text)
self.assertNotIn('请补充箱数',text)
def test_oral_partial_weight_edits_never_overwrite_the_whole_shipment(self):
cid=self.conversation()
self.ask('美国美西30KG,部分铁制品,铁铝部分计费重10KG,义乌普船海派',cid)
for q,amount in (('铁铝部分还是10KG',110),('铁铝部分改成8KG',100),('铁铝部分仍是8KG',100)):
text=self.ask(q,cid)
self.assertIn('美国 30KG',text);self.assertIn(f'30KG = {amount}元',text)
self.assertIn('大于0',self.ask('铁铝部分改为-1KG',cid))
fresh=self.conversation()
self.assertIn('请确认铁铝部分的计费重',self.ask('美国美西30KG,铁铝部分还是10KG,义乌普船海派',fresh))
self.assertNotIn('×10KG',self.ask('一共多少钱,给我最终总价',fresh))
def test_long_quote_chain_retains_conditions_but_topic_changes_stop_reuse(self):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
for _ in range(15):self.assertIn('每箱不足12KG',self.ask('这个报价按毛重还是净重?',cid))
self.assertIn('美国 50KG',self.ask('50KG呢',cid))
self.assertIn('美国渠道',self.ask('这个物流服务范围具体怎么执行?',cid))
self.assertIn('请先',self.ask('下一页',cid))
from logistics_chat.quote_service import answer_quote
from logistics_chat.quote_scope import answer_us_fallback
with self.app.test_request_context('/'):
self.assertIsNone(answer_quote('新的一票50KG发德国报价',cid))
db.save_message(cid,'user','换个话题,我要查订单')
self.assertIsNone(answer_us_fallback('50KG呢',cid))
def test_comparing_two_weights_is_not_treated_as_two_cartons(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
text=self.ask('单箱12.5KG会不会按13KG收费?',cid)
self.assertIn('按原小数计费,不向上取整',text)
self.assertIn('12.5KG',text)
self.assertNotIn('请明确总重',text)
def test_confirmed_fractional_weight_and_partial_metal_basis_are_used(self):
self.assertIn('12.5KG = 25元',self.ask('美国美西1箱12.5KG,40x30x20cm,义乌普船海派'))
self.assertIn('12.3456KG = 24.69元',self.ask('美国美西1箱12.3456KG,40x30x20cm,义乌普船海派'))
cid=self.conversation()
text=self.ask('美国美西30KG,部分铁制品,义乌普船海派',cid)
self.assertIn('30KG = 60元',text);self.assertIn('不能按整票重量加费',text)
self.assertNotIn('30KG = 210元',text)
text=self.ask('铁铝部分计费重10KG',cid)
self.assertIn('美国 30KG',text);self.assertIn('30KG = 110元',text)
self.assertIn('×10KG=50元',text)
self.assertIn('30KG = 110元',self.ask('一共多少钱,给我最终总价',cid))
text=self.ask('改成40KG',cid)
self.assertIn('当前金额未包含该项',text);self.assertNotIn('×10KG',text)
text=self.ask('全部换成铁制品40KG',cid)
self.assertIn('40KG = 280元',text)
def test_unknown_metal_and_cbm_quotes_do_not_spread_partial_fee_over_the_whole_load(self):
text=self.ask('美国美西30KG,只是金属扣子,整票都按铁铝加费吗,义乌普船海派')
self.assertIn('只按铁铝货物的计费重',text);self.assertIn('确认金属是否属于铁铝',text)
text=self.ask('美国30KG发ZZZ1,1方不包税海卡,部分铁制品,铁铝部分计费重10KG,义乌')
self.assertIn('1CBM = 150元',text);self.assertIn('×10KG=50元',text)
self.assertIn('大于0',self.ask('美国30KG,部分铁制品,铁铝部分计费重-1KG,义乌海派'))
text=self.ask('美国20KG发ZZZ1,10KG发YYY2,部分铁制品,铁铝部分计费重10KG,义乌普船海派')
self.assertIn('不能重复套用到每个仓',text)
self.assertIn('20KG = 100元',text);self.assertIn('12KG = 24元',text)
self.assertNotIn('铁铝附加费5元/KG×10KG',text)
def test_booking_phone_invoice_and_file_capabilities_do_not_invent_workflows(self):
cases=(('就按刚才方案帮我下单','尚未创建订单'),('手机号能查吗','暂不支持手机号'),
('你们可以开发票吗,开票加几个点','开票税点'),('付款账号发我一下','收款账号'),
('可以月结吗','月结资格'),('帮我导出订单Excel','暂不提供订单Excel导出'),
('Excel能直接读里面箱规报价吗','不会自动读取Excel'),
('我换了个手机,之前聊天记录还在吗','不会自动同步恢复'))
for q,expected in cases:
with self.subTest(q=q):self.assertIn(expected,self.ask(q))
with patch('logistics_chat.order_data.lookup_order') as lookup:
self.assertIn('暂不支持手机号',self.ask('手机号19900000000能查吗'))
lookup.assert_not_called()
with self.app.app_context():
c=db.connect();self.assertEqual(c.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0],2);c.close()
def test_address_and_pickup_questions_are_not_prices_and_english_stays_english(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
self.assertIn('地址、联系人和收货时间',self.ask('义乌仓具体地址是什么?几点可以送?',cid))
text=self.ask('Can you pick up my goods in Shenzhen for US sea freight?')
self.assertIn('does not offer pickup',text);self.assertNotIn('CNY/kg',text)
self.assertFalse(re.search(r'[\u4e00-\u9fff]',text))
def test_air_battery_question_does_not_apply_sea_battery_requirements(self):
text=self.ask('想走美国空运,包税带电的,有吗?')
self.assertIn('空运费率当前资料没有列出',text);self.assertNotIn('MSDS',text)
self.assertIn('备货阶段',self.ask('美国木制品需要熏蒸吗'))
def seed_pages(self):
with self.app.app_context():
c=db.connect();import_records(c,[{'tenant_id':'qa-a','order_no':f'PAGE2026{i:06d}',
'inbound_code':f'QAPAGE2026{i:06d}','status':'PROCESSING'} for i in range(1,26)])
c.commit();c.close()
def test_pages_use_fresh_authorized_count_and_preserve_filter_and_private_history(self):
self.seed_pages()
self.assertTrue(self.events('下一页')[0]['login_required'])
self.login();cid=self.conversation()
text=self.ask('列出我的全部订单',cid)
self.assertIn('共26票',text);self.assertEqual(text.count('- 入仓码:'),20)
self.assertNotIn('QAOTHER',text)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6);self.assertIn('共26票',text)
self.assertIn('最后一页',self.ask('下一页',cid))
self.assertIn('第1页',self.ask('上一页',cid))
self.ask('列出履约中的订单',cid)
text=self.ask('下一页',cid)
self.assertIn('共25票',text);self.assertEqual(text.count('- 入仓码:'),5);self.assertNotIn('QAFOCUS',text)
self.assertIn('第1页',self.ask('第1页',cid))
self.client.post('/api/customer/logout')
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_page_commands_cannot_reuse_another_topic_or_unbounded_offset(self):
self.login();cid=self.conversation();self.ask('列出我的订单',cid)
self.assertIn('有效页码',self.ask('第0页',cid))
self.ask('列出我的订单',cid)
self.ask('美国30KG海派报价',cid)
self.assertIn('请先',self.ask('下一页',cid))
def test_count_interruptions_do_not_erase_the_active_list_page(self):
self.seed_pages();self.login();cid=self.conversation()
self.ask('列出我的全部订单',cid)
self.ask('我有多少个运单',cid)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6)
def test_clearance_followup_modification_and_unknown_payment_do_not_call_model(self):
self.assertTrue(self.events('我的订单已清关了吗')[0]['login_required'])
self.login();cid=self.conversation();self.events('QAFOCUS20260001',cid)
event=self.events('我的订单已清关了吗?',cid)[0]
self.assertEqual(event['card']['no'],'FOCUS20260001')
self.assertEqual(event['card']['tracking_status'],'海运途中')
self.assertIn('尚未修改订单',self.ask('帮我把收货地址改一下',cid))
self.assertIn('未接入付款状态',self.ask('这票订单付过款了吗',cid))
self.assertIn('不能直接当作快递单号',self.ask('这票对应的UPS快递单号是多少',cid))
self.assertIn('暂不支持按这个物流条件',self.ask('哪些订单还没开船',cid))
+154
View File
@@ -0,0 +1,154 @@
"""Common pre-sale and order questions; all data and monetary values are synthetic."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.order_data import import_records
from logistics_chat.quote_data import import_snapshot
from test_quotes import fixture_snapshot,frames
class CustomerQuestionTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
'address':'','source_sheet':'合成分区','source_cell':'A10'})
documents=['铁铝制品另外加附加费¥5/KG','客户自行送货,不提供上门提货服务',
'外箱要贴两张入仓条码与我司唛头标签,代贴收操作费',
'产品上和包装上必须有Made in China产地标识',
'单独报关需提供资料:装箱单、发票、报关委托书、合同',
'如遇查验,不包税货物的查验费用按客户所占货物比例均摊',
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
'超重收+70/件']
book['documents']=[{'sheet':'合成渠道','cell':f'A{i}','text':text} for i,text in enumerate(documents)]
self.order={'tenant_id':'qa-a','order_no':'COMMON20260001','inbound_code':'QACOMMON20260001',
'status':'COMPLETED','package_count':2,'country_code':'US',
'tracking_json':[{'code':'DEPARTED_ORIGIN_PORT','actual_at':'2026-10-01 10:00'},
{'code':'ARRIVED_DESTINATION_PORT','estimated_at':'2026-10-20 10:00'}]}
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True)
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'COMMON20260099','inbound_code':'QACOMMON20260099'}])
c.commit();c.close()
def conversation(self):return self.client.post('/api/new_chat').json['id']
def events(self,text,cid=None):
with patch('logistics_chat.ai_client.post_completion') as model:
result=frames(self.client.post('/stream_chat',json={'msg':text,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(result[-1],{'d':1},result)
return result
def ask(self,text,cid=None):return self.events(text,cid)[0]['t']
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def test_explicit_weights_are_billed_by_destination_and_not_reused_as_each_total(self):
for text in ('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',
'美国ZZZ1:20KG,YYY2:10KG,义乌普船海派'):
answer=self.ask(text)
self.assertIn('美国 30KG',answer)
self.assertIn('20KG = 100元',answer)
self.assertIn('12KG = 24元',answer)
self.assertNotIn('30KG =',answer)
self.assertIn('分配重量不一致',self.ask('美国总重40KG,20KG发ZZZ1,10KG发YYY2,海派'))
def test_allocation_follows_origin_changes_but_a_single_new_destination_clears_it(self):
cid=self.conversation()
self.ask('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',cid)
text=self.ask('深圳交仓呢',cid)
self.assertIn('20KG = 120元',text);self.assertIn('12KG = 36元',text)
text=self.ask('换成YYY2',cid)
self.assertNotIn('ZZZ1',text);self.assertIn('美国 30KG',text)
def test_allocated_cartons_keep_individual_minimums_and_overweight_fees(self):
text=self.ask('美国2箱每箱5KG发ZZZ1,1箱20KG发YYY2,义乌普船海派')
self.assertIn('美国 30KG',text);self.assertIn('24KG = 120元',text);self.assertIn('20KG = 40元',text)
text=self.ask('美国1箱30KG发ZZZ1,1箱10KG发YYY2,义乌普船海派')
self.assertIn('30KG = 220元',text);self.assertIn('最高项70元/件',text)
text=self.ask('美国1箱10KG发ZZZ1,60x50x40cm,1箱20KG发YYY2,40x30x20cm,义乌普船海派')
self.assertIn('20KG = 100元',text);self.assertIn('20KG = 40元',text)
def test_total_weight_correction_removes_old_per_carton_weight_even_on_next_followup(self):
cid=self.conversation()
self.ask('美国美西2箱每箱30KG,义乌普船海派',cid)
text=self.ask('30公斤是两箱加起来的,不是每箱30公斤',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
text=self.ask('深圳交仓呢',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
self.assertIn('美国 50KG',self.ask('不是30KG,是50KG',cid))
def test_uniform_carton_count_change_preserves_piece_weight_across_followups(self):
cid=self.conversation()
self.ask('美国美西2箱每箱15KG,义乌普船海派',cid)
self.assertIn('美国 60KG',self.ask('改成4箱',cid))
self.assertIn('美国 60KG',self.ask('深圳交仓呢',cid))
self.assertIn('美国 60KG',self.ask('改成6箱,总重不变',cid))
self.assertIn('美国 60KG',self.ask('义乌交仓呢',cid))
def test_mixed_carton_count_requires_new_weights_then_accepts_a_new_total(self):
cid=self.conversation()
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',cid)
self.assertIn('请确认新的总重',self.ask('改成4箱',cid))
text=self.ask('总重40KG',cid)
self.assertIn('美国 40KG',text);self.assertIn('48KG = 96元',text)
other=self.conversation()
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',other)
self.ask('改成4箱,总重不变',other)
self.assertIn('美国 30KG',self.ask('深圳交仓呢',other))
def test_new_dimensions_do_not_resurrect_stale_volume(self):
cid=self.conversation()
self.ask('美国363KG发ZZZ1,1箱,1.5方,义乌不包税海卡',cid)
self.assertIn('1CBM = 100元',self.ask('改成每箱50x50x40cm',cid))
self.assertIn('1CBM = 100元',self.ask('深圳交仓呢',cid))
def test_cbm_metal_surcharge_is_separate_instead_of_silently_included(self):
text=self.ask('美国铁制品30KG发ZZZ1,1方,义乌不包税海卡')
self.assertIn('1CBM = 100元',text)
self.assertIn('铁铝附加费为5元/KG',text)
self.assertIn('当前按方运费未包含',text)
self.assertNotIn('= 105元',text)
def test_preparation_questions_use_us_material_without_promising_ship_dates(self):
cases=(('美国可以上门提货吗','不提供上门提货'),('美国FBA外箱怎么贴标签','两张入仓条码'),
('美国单独报关资料需要什么','装箱单'),('美国申报比例怎么算','不能按统一比例'),
('美国不包税查验费用谁承担','比例均摊'),('美国这周哪天开船','联系人工核对'))
for question,expected in cases:
with self.subTest(question=question):self.assertIn(expected,self.ask(question))
cid=self.conversation();self.ask('美国30KG海派报价',cid)
self.assertIn('唛头',self.ask('外箱标签呢',cid))
self.assertIn('目的国家',self.ask('可以上门提货吗'))
self.assertIn('普船海派',self.ask('你们能发美国吗,有哪些渠道'))
text=self.ask('美国美西30KG最便宜的,月底必须到,义乌海派')
self.assertIn('30KG = 60元',text);self.assertIn('不能作为',text)
def test_order_business_status_does_not_imply_signing_shelving_or_piece_delivery(self):
self.assertIn('业务完成不等于',self.ask('头程订单已完成是不是签收了'))
self.assertTrue(self.events('这票上架了吗')[0]['login_required'])
self.login();cid=self.conversation()
self.events(self.order['inbound_code'],cid)
text=self.ask('这票已完成为什么还没上架',cid)
self.assertIn('已离开起运港',text)
self.assertIn('不等于收件人签收',text)
self.assertNotIn('已经到港',text)
text=self.ask('怎么三天都没更新',cid)
self.assertIn('2026-10-01',text);self.assertIn('原因无法',text)
text=self.ask('到了几箱?少了一箱',cid)
self.assertIn('登记总件数为2件',text);self.assertIn('没有逐箱签收明细',text)
text=self.ask('帮我催一下',cid)
self.assertIn('尚未提交',text)
self.assertIn('未找到',self.ask('QACOMMON20260099上架了吗'))
self.client.post('/api/customer/logout')
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs', 'tests/frontend/reply.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+5 -4
View File
@@ -78,14 +78,15 @@ class OrderCountTests(unittest.TestCase):
'What is the number of my orders?'):
with self.subTest(question=question):
events = self.events(question,cid)
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
self.assertTrue(events[0]['t'].startswith('您当前共有 2 票订单,其中 1 票已登记入仓码。'))
self.assertEqual(events[0]['t'].count('- 入仓码:'),2)
self.assertEqual(events[-1],{'d':1})
model.assert_not_called()
with self.app.app_context():
connection = db.connect()
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
self.login()
@@ -152,7 +153,7 @@ class OrderCountTests(unittest.TestCase):
with self.app.app_context():
with self.assertRaises(order_data.OrderDataUnavailable):
order_data.count_orders(('tenant-a',))
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
with patch('logistics_chat.order_listing.read_orders',side_effect=sqlite3.OperationalError):
events = self.events('我有多少个运单')
self.assertIn('暂时无法读取',events[0]['t'])
@@ -164,7 +165,7 @@ class OrderCountTests(unittest.TestCase):
(' \t\r\n','COUNT20260002','tenant-a'))
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
with self.app.app_context():
connection = db.connect()
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
+473
View File
@@ -0,0 +1,473 @@
"""Statistics use explicit exception reasons and actual, order-level progress."""
import json
import re
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db,order_data
from logistics_chat.order_tracking import current_stage
def fact(code,shipment='',forecast=False):
return {'code':code,'shipment_key':shipment,
'estimated_at' if forecast else 'actual_at':'2026-10-01 10:00'}
class OrderStatisticsTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup);root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
rows=[{'tenant_id':'a','order_no':f'STAT2026{i:04}','inbound_code':f'INSTAT2026{i:04}',
'status':'PROCESSING','source_updated_at':f'2026-10-{i+1:02} 10:00',
'tracking_json':[fact('IN_TRANSIT'),fact('ARRIVED_DESTINATION_PORT'),fact('EMPTY_RETURNED',forecast=True)],
'exception_reason':f'待核实资料{i}' if i<6 else None} for i in range(10)]
rows += [{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING',
'tracking_json':[fact('ARRIVED_DESTINATION_PORT'),fact('PICKUP_READY')],'exception_reason':'等待客户资料'},
{'tenant_id':'a','order_no':'CLEAR20260002','status':'PROCESSING','tracking_json':[fact('PICKUP_READY')]},
{'tenant_id':'a','order_no':'MIXED20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B')]},
{'tenant_id':'a','order_no':'FORECAST20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT',forecast=True)]},
{'tenant_id':'a','order_no':'BUSINESS20260001','status':'COMPLETED','tracking_json':'[]'},
{'tenant_id':'a','order_no':'REJECTED20260001','status':'REJECTED','exception_reason':' \t\n'},
{'tenant_id':'b','order_no':'SECRET20260001','status':'PROCESSING','tracking_json':[fact('ARRIVED_DESTINATION_PORT')],'exception_reason':'不能泄露'}]
with self.app.app_context():
c=db.connect();order_data.import_records(c,rows);c.commit();c.close()
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def ask(self,q,cid=None):
with patch('logistics_chat.ai_client.post_completion') as model:
response=self.client.post('/stream_chat',json={'msg':q,'conv_id':cid})
events=[json.loads(line[6:]) for line in response.get_data(as_text=True).split('\n\n') if line.startswith('data: ')]
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]
def test_guests_never_read_aggregate_or_exception_facts(self):
with patch('logistics_chat.order_listing.read_orders') as read:
for q in ('我有多少异常订单','到港的有多少运单','各个轨迹节点分别有多少订单','查看全部','下一页'):
self.assertTrue(self.ask(q)['login_required'])
read.assert_not_called()
def test_overview_is_requested_in_chat_and_saved_in_customer_protected_history(self):
with patch('logistics_chat.order_listing.read_overview') as read:
for question in ('我的订单概览','查看订单情况','Order overview'):
self.assertTrue(self.ask(question)['login_required'])
read.assert_not_called()
self.login()
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.assertTrue(any(row['role']=='assistant' and row['content']==text for row in response.json))
self.client.post('/api/customer/logout')
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()
with self.app.app_context():
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()
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.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.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):
self.login()
total=self.ask('我有多少个订单')['t']
self.assertIn('共有 16 票',total);self.assertEqual(total.count('- 入仓码:'),5)
abnormal=self.ask('我有多少个异常订单')['t']
self.assertIn('7 票明确标记异常',abnormal);self.assertEqual(abnormal.count('- 入仓码:'),5)
self.assertNotIn('REJECTED20260001',abnormal)
arrived=self.ask('现在到港的有多少运单')['t']
self.assertIn('10 票当前节点为“船舶已到目的港”',arrived)
self.assertEqual(arrived.count('- 入仓码:'),5)
self.assertIn('STAT20260009',arrived);self.assertNotIn('STAT20260000',arrived)
self.assertNotIn('CLEAR20260001',arrived)
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_short_choice_confirmation_preserves_the_selected_order_and_navigation(self):
self.login()
cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
first=self.ask('第一个呢',cid)['card']['no']
second=self.ask('下一票呢',cid)['card']['no']
self.ask('就这个',cid)
with self.app.app_context():
connection=db.connect()
reason=connection.execute('SELECT exception_reason FROM order_test_data WHERE order_no=?',(second,)).fetchone()[0]
connection.close()
self.assertIn(reason,self.ask('异常原因呢',cid)['t'])
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']
for token in ('船舶已到目的港:10票','已清关、可提柜:2票','多柜进度不同:1票','暂无实际节点:3票'):
self.assertIn(token,text)
self.assertNotIn('已还柜:',text);self.assertNotIn('已签收:',text)
def test_english_homepage_questions_apply_their_intended_filters(self):
self.assertTrue(self.ask('How many exception orders do I have?')['login_required'])
self.login()
self.assertIn('7 票明确标记异常',self.ask('How many exception orders do I have?')['t'])
text=self.ask('How many orders are at each tracking stage?')['t']
self.assertIn('船舶已到目的港:10票',text)
self.assertIn('暂无实际节点:3票',text)
def test_expand_and_pages_keep_filters_and_refresh_counts(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
text=self.ask('下一页',cid)['t']
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),2)
text=self.ask('查看全部',cid)['t']
self.assertIn('共7票',text);self.assertEqual(text.count('- 入仓码:'),7)
self.assertIn('明确标记异常',text)
with self.app.app_context():
c=db.connect();c.execute('UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id=?',('a',));c.commit();c.close()
self.assertIn('没有符合条件',self.ask('下一页',cid)['t'])
def test_pages_do_not_cross_conversations_or_login_boundaries(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('到港的订单有多少',cid)
self.assertIn('第2页',self.ask('下一页',cid)['t'])
self.ask('当前订单总数',cid)
self.assertIn('共16票',self.ask('查看全部',cid)['t'])
self.client.post('/api/customer/logout')
self.assertTrue(self.ask('下一页',cid)['login_required'])
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login();self.assertIn('共16票',self.ask('查看全部',cid)['t'])
other=self.client.post('/api/new_chat').json['id']
self.assertIn('请先',self.ask('下一页',other)['t'])
def test_unknown_or_ambiguous_filters_do_not_degenerate_to_totals(self):
self.login()
for q in ('今天有多少异常订单','未到港的订单有多少','到港和已清关分别有多少订单','已签收的有多少运单'):
with self.subTest(q=q):self.assertIn('暂不支持按这个条件统计',self.ask(q)['t'])
def test_exception_reasons_are_editable_and_old_reimports_preserve_them(self):
self.login()
with self.app.app_context():
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING'}],replace=True);c.commit();c.close()
self.assertIn('等待客户资料',self.ask('CLEAR20260001的异常原因是什么')['t'])
cid=self.client.post('/api/new_chat').json['id'];self.ask('CLEAR20260001',cid)
self.assertIn('等待客户资料',self.ask('为什么异常',cid)['t'])
with self.app.app_context():
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','exception_reason':''}],replace=True);c.commit();c.close()
self.assertIn('未登记异常原因',self.ask('CLEAR20260001的异常原因是什么')['t'])
def test_missing_actuals_and_bad_tracking_do_not_count_as_arrived(self):
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B',True)]),'MIXED')
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT',forecast=True)]),'NO_ACTUAL')
self.assertEqual(current_stage('{broken'),'NO_ACTUAL')
self.assertEqual(current_stage([fact('UNKNOWN')]),'NO_ACTUAL')
self.assertEqual(current_stage([fact('DOMESTIC_RECEIVED'),fact('ARRIVED_DESTINATION_PORT','A',True)]),'DOMESTIC_RECEIVED')
def test_node_names_do_not_collide_with_business_status_or_drop_negations(self):
from logistics_chat.order_query import parse_query
intent=parse_query('已完成装柜的订单有多少')
self.assertFalse(intent['unsupported']);self.assertIsNone(intent['status'])
self.assertEqual(intent['stage'],'LOADING_COMPLETED')
self.assertTrue(parse_query('未清关的订单有多少')['unsupported'])
self.assertTrue(parse_query('没有异常的订单有多少')['unsupported'])
def test_state_survives_app_restart_and_rejects_late_reply_updates(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
restarted=create_app(dict(self.app.config,INITIALIZE_RUNTIME=False))
client=restarted.test_client()
# Copy opaque cookies without exporting them.
for cookie in self.client._cookies.values():client.set_cookie(cookie.key,cookie.value)
response=client.post('/stream_chat',json={'msg':'下一页','conv_id':cid}).get_data(as_text=True)
self.assertIn('第2页',response)
from logistics_chat.order_listing import save_state
cookies='; '.join(cookie.key+'='+cookie.value for cookie in self.client._cookies.values())
with self.app.test_request_context('/',headers={'Cookie':cookies}):
self.app.preprocess_request()
from logistics_chat.customer_service import current_customer_id
self.assertIsNotNone(current_customer_id())
c=db.connect();prior=c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
state=json.loads(prior);c.close()
db.save_message(cid,'user','换个话题,我要报价')
save_state(cid,{'query':state['query'],'page':1,'size':5},state['message_id'])
c=db.connect();self.assertEqual(c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0],prior);c.close()
def test_schema_migration_preserves_old_orders(self):
c=sqlite3.connect(':memory:')
try:
c.execute('CREATE TABLE order_test_data (id INTEGER PRIMARY KEY,tenant_id TEXT,order_no TEXT,status TEXT,inbound_code TEXT,shipment_id TEXT)')
c.execute("INSERT INTO order_test_data VALUES(1,'a','OLD20260001','PROCESSING',NULL,NULL)")
order_data.init_order_table(c);order_data.init_order_table(c)
self.assertEqual(c.execute('SELECT order_no,exception_reason,tracking_json FROM order_test_data').fetchone(),('OLD20260001',None,'[]'))
finally:c.close()
+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)
+214
View File
@@ -0,0 +1,214 @@
"""Relative quantity and condition edits keep the previously confirmed quote basis."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.quote_data import import_snapshot
from test_quote_timing import timing_snapshot
from test_quotes import frames
class QuoteChangeTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'5'*64})
self.cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
c=db.connect();import_snapshot(c,timing_snapshot(),activate=True);c.commit();c.close()
def ask(self,question):
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':self.cid}))
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
self.assertFalse(any(event.get('e') for event in events),events)
return events[0]['t']
def test_weight_increments_are_added_and_absolute_changes_replace_the_total(self):
self.ask('美国1000KG义乌美东普船海派报价')
self.assertIn('1100KG = 6600元',self.ask('再加100KG,其他不变'))
self.assertIn('900KG = 5400元',self.ask('再减掉200KG'))
self.assertIn('900KG = 5400元',self.ask('那一共多少钱'))
self.assertIn('800KG = 4800元',self.ask('减到800KG'))
self.assertIn('900KG = 5400元',self.ask('增加0.1吨,总重900KG'))
def test_invalid_or_unitless_changes_stay_pending_until_a_new_total_is_confirmed(self):
self.ask('美国30KG义乌美西普船海派报价')
self.assertIn('调整后的总重需要大于0',self.ask('减去40KG'))
text=self.ask('换成美东')
self.assertIn('请提供调整后的总重',text)
self.assertNotIn('KG =',text)
self.assertIn('20KG = 120元',self.ask('总重20KG'))
self.assertIn('请提供调整后的总重',self.ask('再加100'))
self.assertNotIn('KG =',self.ask('具体多少'))
self.assertIn('50KG = 300元',self.ask('新总重50KG'))
def test_relative_quantity_without_a_confirmed_starting_value_requests_a_total(self):
self.assertIn('原来的总重',self.ask('再加100KG'))
self.assertIn('调整后的总重',self.ask('美国普船海派'))
self.assertIn('200KG = 800元',self.ask('总重200KG,义乌美西'))
def test_uniform_cartons_add_and_remove_without_losing_the_previous_cartons(self):
self.ask('美国10箱,每箱20KG,义乌美西普船海派报价')
self.assertIn('240KG = 960元',self.ask('再加2箱,每箱20KG'))
text=self.ask('减少1箱')
self.assertIn('220KG = 880元',text)
self.assertIn('共11箱',text)
self.assertIn('220KG = 880元',self.ask('那一共呢'))
def test_added_carton_dimensions_apply_to_the_added_cartons_only(self):
self.ask('美国1箱,每箱20KG,30x30x30cm,义乌美西普船海派报价')
text=self.ask('再加1箱,每箱10KG,60x50x40cm')
self.assertIn('40KG = 160元',text)
self.assertNotIn('50KG =',text)
def test_each_carton_minimum_remains_in_effect_after_addition(self):
self.ask('美国2箱,每箱5KG,义乌美西普船海派报价')
self.assertIn('36KG = 144元',self.ask('再加1箱,每箱5KG'))
def test_declared_new_totals_are_validated_after_a_carton_change(self):
self.ask('美国10箱每箱20KG,义乌美西普船海派报价')
self.assertIn('240KG = 960元',self.ask('再加2箱每箱20KG,总重240KG'))
self.assertIn('增减箱数推算的总重',self.ask('再加1箱每箱20KG,总重300KG'))
self.assertNotIn('KG =',self.ask('换成华南'))
self.assertIn('300KG',self.ask('总重300KG,共13箱'))
def test_heterogeneous_carton_removal_requires_the_updated_breakdown(self):
self.ask('美国1箱10KG,1箱30KG,义乌美西普船海派报价')
self.assertIn('不同箱规',self.ask('减少1箱'))
self.assertNotIn('KG =',self.ask('具体多少钱'))
self.assertIn('30KG = 120元',self.ask('现在1箱30KG'))
def test_rejected_services_remain_excluded_until_explicitly_selected_again(self):
self.ask('美国1000KG义乌美东海运最便宜报价')
text=self.ask('不走普船了,改成美森正班')
self.assertIn('1000KG = 10000元',text)
self.assertNotIn('普船海派',text)
text=self.ask('不要美森,走普船')
self.assertIn('1000KG = 6000元',text)
self.assertNotIn('美森',text)
self.ask('不走普船')
text=self.ask('改成华南')
self.assertNotIn('普船海派',text)
self.assertNotIn('美森',text)
self.assertIn('合德海派',text)
self.assertIn('普船海派',self.ask('那还是普船吧'))
self.assertIn('美森正班海派',self.ask('全部渠道报价'))
def test_tax_corrections_and_pending_conflicts_are_not_reversed(self):
self.ask('美国1000KG义乌美东海派报价')
self.assertIn('1000KG = 6000元',self.ask('不要不包税,我要包税'))
self.assertIn('没有匹配',self.ask('不是包税,是不包税'))
self.assertIn('1000KG = 6000元',self.ask('那还是包税吧'))
self.assertIn('1000KG = 6000元',self.ask('不是包税吗?'))
self.assertIn('明确选择包税',self.ask('不要包税,也不要不包税'))
self.assertNotIn('KG =',self.ask('换成华南'))
self.assertIn('1000KG = 6000元',self.ask('要包税'))
def test_new_batch_with_rejections_resets_old_shipping_conditions(self):
self.ask('美国1000KG义乌美东普船海派报价')
text=self.ask('新的一批美国30KG海派报价,不走普船')
self.assertIn('美国 30KG',text)
self.assertIn('华南',text)
self.assertIn('美西',text)
self.assertNotIn('普船海派',text)
self.assertNotIn('1000KG',text)
def test_calendar_questions_preserve_route_and_request_the_missing_dates(self):
self.ask('美国1000KG义乌美东普船海派报价')
for question in ('什么时候能到','大概哪天能收到'):
text=self.ask(question)
self.assertIn('25-30天左右提取',text)
self.assertIn('预计开船日期',text)
text=self.ask('快一点呢')
self.assertIn('美森正班海派',text)
self.assertNotIn('普船海派',text)
text=self.ask('那比慢的贵多少')
self.assertIn('运费差额为4000元',text)
def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self):
self.ask('美国1000KG海运最快和最慢报价')
self.assertIn('1000KG',self.ask('这个多少钱'))
self.assertIn('请指定方案名称',self.ask('第二个方案呢'))
self.assertIn('1000KG = 4000元',self.ask('便宜的那个'))
self.assertIn('1000KG = 4000元',self.ask('一共呢'))
def test_tied_speed_costs_produce_a_range_without_mixing_tax_or_units(self):
from logistics_chat.quote_comparison import speed_difference_lines
from logistics_chat.quote_data import load_book
from logistics_chat.quote_intent import parse_intent
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
rates=[{**base,'service':name,'price':price,'eta':eta} for name,price,eta in (
('快线A','8','12-15天'),('快线B','10','12-15天'),
('慢线A','5','30-35天'),('慢线B','6','30-35天'))]
rates.append({**base,'service':'自税线','tax':'excluded','price':'999','eta':'20天'})
rates.append({**base,'service':'按方线','unit':'cbm','price':'999','eta':'20天'})
with self.app.app_context():
book=load_book()
intent=parse_intent('美国1000KG义乌美东海派报价',set())
lines=speed_difference_lines(intent,book,rates,None)
self.assertTrue(any(' · 包税 · ' in line and '2000至5000元' in line for line in lines),lines)
self.assertTrue(any('不包税' in line and '差额为0元' in line for line in lines),lines)
self.assertEqual(len(lines),2)
self.assertEqual(speed_difference_lines(intent,book,[{**rates[0],'eta':'未提供时效'}],None),[])
def test_polite_relative_edits_and_unrecognized_adjustments_do_not_replace_the_total(self):
self.ask('美国1000KG义乌美东普船海派报价')
self.assertIn('1100KG = 6600元',self.ask('那我再加一百公斤'))
self.assertIn('900KG = 5400元',self.ask('请帮我减少200KG'))
self.assertIn('调整后的总重',self.ask('这批货后面增加100KG'))
self.assertNotIn('KG =',self.ask('具体多少钱'))
def test_difference_uses_the_displayed_two_decimal_costs(self):
from logistics_chat.quote_comparison import speed_difference_lines
from logistics_chat.quote_data import load_book
from logistics_chat.quote_intent import parse_intent
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
rates=[{**base,'service':'快线','price':'6.4','eta':'12天'},
{**base,'service':'慢线','price':'4.3','eta':'30天'}]
with self.app.app_context():
intent=parse_intent('美国12.555KG义乌美东海派报价',set())
text='\n'.join(speed_difference_lines(intent,load_book(),rates,None))
self.assertIn('差额为26.36元',text)
def test_difference_shows_both_speeds_for_each_origin_used_in_the_calculation(self):
with self.app.app_context():
c=db.connect()
c.execute("UPDATE quote_rates SET eta='船开后16天左右' WHERE service='美森正班海派' AND origin='华南'")
c.commit();c.close()
text=self.ask('美国1000KG美东海派最快最慢差价')
self.assertIn('美森正班海派 · 义乌',text)
self.assertIn('美森正班海派 · 华南',text)
self.assertEqual(text.count('运费差额为4000元'),2)
def test_volume_increments_keep_the_confirmed_total_and_units(self):
self.ask('美国2CBM义乌美东普船海派报价')
text=self.ask('再加1方')
self.assertIn('总体积为3CBM',text)
self.assertIn('3000元',text)
text=self.ask('总体积减少0.5CBM')
self.assertIn('总体积为2.5CBM',text)
self.assertIn('2500元',text)
def test_carton_changes_with_an_aggregate_volume_wait_for_the_updated_volume(self):
self.ask('美国2箱每箱20KG,1CBM,义乌美东普船海派报价')
self.assertIn('调整后的总体积',self.ask('再加1箱每箱20KG'))
text=self.ask('总重60KG')
self.assertIn('调整后的总体积',text)
self.assertNotIn('KG =',text)
text=self.ask('总体积1.5CBM')
self.assertIn('1500元',text)
def test_unrecognized_volume_increment_is_clarified_without_using_it_as_a_new_total(self):
self.ask('美国2CBM义乌美东普船海派报价')
text=self.ask('这批货后面增加1方')
self.assertIn('调整后的总体积',text)
self.assertNotIn('元/KG',text)
self.assertIn('3000元',self.ask('总体积3CBM'))
+232
View File
@@ -0,0 +1,232 @@
"""Compound quote questions retain shipment facts and local service terms."""
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_intent import timing_with_claim
from test_quote_timing import timing_snapshot
from test_quotes import frames
class QuoteConversationTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'history.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':'9'*64})
self.cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
connection=db.connect()
import_snapshot(connection,timing_snapshot(),activate=True)
connection.commit()
connection.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_fastest_and_cheapest_in_one_question_both_receive_prices(self):
self.ask('一票3000KG发美国,义乌美东,价格怎么样')
text=self.ask('最快的多少钱,最便宜的是什么')
fast,cheap=text.split('最便宜参考方案')
self.assertIn('最快参考方案',fast)
self.assertIn('美森正班海派',fast)
self.assertIn('3000KG = 30000元',fast)
self.assertIn('普船海派',cheap)
self.assertIn('3000KG = 18000元',cheap)
self.assertNotIn('华南',text)
def test_recommendation_keeps_quantity_and_can_be_followed_by_a_quantity_edit(self):
self.ask('美国3000KG义乌美东海派报价')
text=self.ask('请帮我推荐一下,怎么走比较好')
self.assertIn('最快参考方案',text)
self.assertIn('最便宜参考方案',text)
self.assertIn('预算还是时效',text)
self.assertIn('3000KG = 18000元',text)
text=self.ask('再加100KG,其他不变')
self.assertIn('3100KG = 18600元',text)
self.assertIn('3100KG = 31000元',text)
self.assertNotIn('华南',text)
def test_recommendation_respects_an_explicit_budget_preference(self):
self.ask('美国3000KG义乌美东海派报价')
text=self.ask('推荐最便宜的')
self.assertIn('优先运费',text)
self.assertIn('普船海派',text)
self.assertNotIn('美森正班海派',text)
self.assertNotIn('预算还是时效',text)
def test_answers_to_the_recommendation_preference_question_select_the_route(self):
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
text=self.ask('预算优先')
self.assertIn('普船海派',text)
self.assertIn('3000KG = 18000元',text)
self.assertNotIn('美森正班海派',text)
text=self.ask('时效优先')
self.assertIn('美森正班海派',text)
self.assertIn('3000KG = 30000元',text)
self.assertNotIn('普船海派',text)
def test_natural_cost_preference_selects_the_cheapest_after_recommendation(self):
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
text=self.ask('那我想省钱')
self.assertIn('3000KG = 18000元',text)
self.assertNotIn('美森正班海派',text)
def test_short_choice_confirmation_preserves_the_last_selected_quote(self):
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
self.ask('时效优先')
self.ask('就选这个')
self.assertIn('15天左右提取',self.ask('多久能提取'))
self.assertIn('3000KG = 30000元',self.ask('那一共多少钱'))
def test_quantity_changes_continue_after_a_short_confirmation(self):
self.ask('美国3000KG义乌美东美森正班海派报价')
self.ask('再加500KG')
self.ask('就按这个')
self.assertIn('2500KG = 25000元',self.ask('再减一吨'))
def test_no_book_for_a_named_us_service_does_not_use_legacy_regional_knowledge(self):
with patch('logistics_chat.quote_service.load_book',return_value=None), \
patch('logistics_chat.quote_data.load_book',return_value=None):
text=self.ask('美森正班海派多久能到')
self.assertIn('美国报价表',text)
self.assertNotIn('欧洲',text)
self.assertNotIn('15天',text)
def test_recommendation_without_shipment_details_asks_for_them(self):
text=self.ask('请帮我推荐一下,怎么走比较好')
self.assertIn('目的国家',text)
self.assertIn('货物重量',text)
self.assertNotIn('元/KG',text)
def test_explicit_service_after_comparison_clears_comparison_labels(self):
self.ask('美国3000KG义乌美东海派最快和最便宜报价')
text=self.ask('美森正班海派多久能到')
self.assertIn('15天左右提取',text)
self.assertNotIn('最快参考方案',text)
self.assertNotIn('最便宜参考方案',text)
self.assertNotIn('普船海派',text)
self.assertIn('3000KG = 30000元',self.ask('具体多少钱'))
def test_timing_and_liability_are_answered_without_losing_the_quote(self):
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET eta=eta||? WHERE service=?",('\n无时效赔偿','美森正班海派'))
connection.commit()
connection.close()
self.ask('美国3000KG义乌美东海派报价')
self.ask('请帮我推荐一下')
text=self.ask('美森正班海派多久能到?如果到不了产生费用,你们公司会赔钱嘛?')
self.assertIn('15天左右提取',text)
self.assertIn('无时效赔偿',text)
self.assertIn('人工根据实际承运条款核实',text)
self.assertNotIn('请提供目的国家',text)
text=self.ask('美森正班海派多久能到')
self.assertIn('15天左右提取',text)
self.assertNotIn('欧洲',text)
self.assertNotIn('无时效赔偿',text)
self.assertIn('3000KG = 30000元',self.ask('具体多少钱'))
def test_mixed_claim_wording_with_punctuation_preserves_known_conditions(self):
self.ask('美国3000KG义乌美东海派报价')
for question in ('美森正班海派多久能到,延误会赔偿吗?',
'美森正班海派多久能到?产生的费用谁负责?'):
text=self.ask(question)
self.assertIn('15天左右提取',text)
self.assertIn('人工根据实际承运条款核实',text)
self.assertIn('3000KG = 30000元',self.ask('一共多少钱'))
def test_claim_processing_time_is_not_treated_as_shipping_time(self):
self.assertFalse(timing_with_claim('理赔需要多久'))
self.assertFalse(timing_with_claim('赔偿处理时效是几天'))
self.assertTrue(timing_with_claim('美森多久到,如果晚到会赔吗'))
def test_missing_liability_terms_are_not_invented(self):
text=self.ask('美森正班海派多久能到,延误会赔吗?')
self.assertIn('15天左右提取',text)
self.assertIn('实际承运条款核实',text)
self.assertNotIn('无时效赔偿',text)
def test_partial_liability_terms_are_not_applied_to_every_origin(self):
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET eta=eta||? WHERE service=? AND origin=?",
('\n无时效赔偿','美森正班海派','义乌'))
connection.commit()
connection.close()
text=self.ask('美森正班海派多久到,延误会赔偿吗?')
self.assertIn('部分匹配方案注明',text)
self.assertNotIn('美森正班海派的渠道资料注明',text)
def test_fresh_named_us_service_uses_local_timing_without_a_country_prompt(self):
text=self.ask('美森正班海派多久能到')
self.assertIn('15天左右提取',text)
self.assertNotIn('欧洲',text)
self.assertNotIn('请提供目的国家',text)
self.assertNotIn('KG =',text)
def test_existing_bad_assistant_replies_do_not_prevent_context_recovery(self):
self.ask('一票3000KG发美国,义乌美东海派报价')
with self.app.app_context():
for question,reply in (
('请帮我推荐一下,怎么走比较好','请说明您想确认的渠道和费用。'),
('美森正班海派多久能到?如果晚到会赔偿吗?','请提供目的国家。'),
('美森正班海派多久能到','知识库只有欧洲海运信息。'),
):
db.save_message(self.cid,'user',question)
db.save_message(self.cid,'assistant',reply)
text=self.ask('那美森正班海派呢')
self.assertIn('15天左右提取',text)
self.assertIn('3000KG = 30000元',text)
self.assertNotIn('欧洲',text)
def test_other_country_context_is_not_silently_replaced_by_a_service_name(self):
with self.app.app_context():
db.save_message(self.cid,'user','新的一票100KG发德国海运报价')
db.save_message(self.cid,'assistant','请核对对应目的地报价。')
text=self.ask('美森正班海派多久能到')
self.assertIn('确认这票是否发往美国',text)
self.assertNotIn('15天',text)
self.assertNotIn('元/KG',text)
def test_known_us_warehouse_can_replace_another_destination_without_its_old_weight(self):
with self.app.app_context():
db.save_message(self.cid,'user','新的一票100KG发德国海运报价')
db.save_message(self.cid,'assistant','请核对对应目的地报价。')
text=self.ask('ZZZ1报价')
self.assertIn('美国',text)
self.assertNotIn('100KG',text)
self.assertNotIn('确认这票是否发往美国',text)
def test_cheapest_is_compared_by_price_instead_of_slowest_timing(self):
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET price='3' WHERE service='合德海派'")
connection.commit()
connection.close()
self.ask('美国3000KG义乌美东海派报价')
text=self.ask('最快和最便宜相差多少钱')
cheap=text.split('最便宜参考方案')[1]
self.assertIn('合德海派',cheap)
self.assertIn('最快与最便宜方案可计算运费差额为21000元',text)
self.assertNotIn('最慢参考方案',text)
def test_english_fastest_and_cheapest_have_both_headings(self):
text=self.ask('3000kg to USA by sea, fastest and cheapest prices please')
self.assertIn('Fastest reference options',text)
self.assertIn('Cheapest reference options',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)