Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2409bb5c36 | ||
|
|
a98ae0b8c6 | ||
|
|
31a92f25f4 | ||
|
|
98eae3641d | ||
|
|
5fa6471929 | ||
|
|
bcfccc7013 | ||
|
|
0f01a4a833 | ||
|
|
33e166b104 | ||
|
|
7b3b3d30ba | ||
|
|
311b9bd57d | ||
|
|
d5692434ac | ||
|
|
5f1c4043d4 | ||
|
|
97ec6ed44b | ||
|
|
f777867749 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" .
|
||||
'''
|
||||
}
|
||||
|
||||
@@ -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 配置。
|
||||
|
||||
@@ -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
@@ -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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
以下参数由私有灰度配置提供,实际地址和凭据值不写入源码:
|
||||
|
||||
| 参数 | 含义 |
|
||||
|
||||
@@ -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'})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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':'订单信息暂时无法读取,请稍后重试。'}
|
||||
@@ -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':'订单信息暂时无法读取,请稍后重试。'}
|
||||
@@ -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')}
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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')))
|
||||
|
||||
@@ -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 '),
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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':'报价数据暂时无法读取,请稍后重试。'}
|
||||
|
||||
@@ -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
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
@@ -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)};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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,[])
|
||||
@@ -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)
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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()
|
||||
@@ -114,7 +114,7 @@ class QuoteBusinessTests(unittest.TestCase):
|
||||
text=self.ask('海运和空运哪个好,美国30KG')
|
||||
self.assertIn('没有美国空运费率',text)
|
||||
text=self.ask('美国美西海派多久')
|
||||
self.assertIn('提取不等于送达',text);self.assertNotIn('元/KG',text)
|
||||
self.assertIn('按开船后到提取计算',text);self.assertNotIn('元/KG',text)
|
||||
text=self.ask('Please quote 30kg to US by sea')
|
||||
self.assertIn('CNY/kg',text)
|
||||
self.assertFalse(__import__('re').search(r'[\u4e00-\u9fff]',text),text)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Relative quantity and condition edits keep the previously confirmed quote basis."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app,db
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from test_quote_timing import timing_snapshot
|
||||
from test_quotes import frames
|
||||
|
||||
|
||||
class QuoteChangeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'5'*64})
|
||||
self.cid=self.client.post('/api/new_chat').json['id']
|
||||
with self.app.app_context():
|
||||
c=db.connect();import_snapshot(c,timing_snapshot(),activate=True);c.commit();c.close()
|
||||
|
||||
def ask(self,question):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':self.cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
self.assertFalse(any(event.get('e') for event in events),events)
|
||||
return events[0]['t']
|
||||
|
||||
def test_weight_increments_are_added_and_absolute_changes_replace_the_total(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
self.assertIn('1100KG = 6600元',self.ask('再加100KG,其他不变'))
|
||||
self.assertIn('900KG = 5400元',self.ask('再减掉200KG'))
|
||||
self.assertIn('900KG = 5400元',self.ask('那一共多少钱'))
|
||||
self.assertIn('800KG = 4800元',self.ask('减到800KG'))
|
||||
self.assertIn('900KG = 5400元',self.ask('增加0.1吨,总重900KG'))
|
||||
|
||||
def test_invalid_or_unitless_changes_stay_pending_until_a_new_total_is_confirmed(self):
|
||||
self.ask('美国30KG义乌美西普船海派报价')
|
||||
self.assertIn('调整后的总重需要大于0',self.ask('减去40KG'))
|
||||
text=self.ask('换成美东')
|
||||
self.assertIn('请提供调整后的总重',text)
|
||||
self.assertNotIn('KG =',text)
|
||||
self.assertIn('20KG = 120元',self.ask('总重20KG'))
|
||||
self.assertIn('请提供调整后的总重',self.ask('再加100'))
|
||||
self.assertNotIn('KG =',self.ask('具体多少'))
|
||||
self.assertIn('50KG = 300元',self.ask('新总重50KG'))
|
||||
|
||||
def test_relative_quantity_without_a_confirmed_starting_value_requests_a_total(self):
|
||||
self.assertIn('原来的总重',self.ask('再加100KG'))
|
||||
self.assertIn('调整后的总重',self.ask('美国普船海派'))
|
||||
self.assertIn('200KG = 800元',self.ask('总重200KG,义乌美西'))
|
||||
|
||||
def test_uniform_cartons_add_and_remove_without_losing_the_previous_cartons(self):
|
||||
self.ask('美国10箱,每箱20KG,义乌美西普船海派报价')
|
||||
self.assertIn('240KG = 960元',self.ask('再加2箱,每箱20KG'))
|
||||
text=self.ask('减少1箱')
|
||||
self.assertIn('220KG = 880元',text)
|
||||
self.assertIn('共11箱',text)
|
||||
self.assertIn('220KG = 880元',self.ask('那一共呢'))
|
||||
|
||||
def test_added_carton_dimensions_apply_to_the_added_cartons_only(self):
|
||||
self.ask('美国1箱,每箱20KG,30x30x30cm,义乌美西普船海派报价')
|
||||
text=self.ask('再加1箱,每箱10KG,60x50x40cm')
|
||||
self.assertIn('40KG = 160元',text)
|
||||
self.assertNotIn('50KG =',text)
|
||||
|
||||
def test_each_carton_minimum_remains_in_effect_after_addition(self):
|
||||
self.ask('美国2箱,每箱5KG,义乌美西普船海派报价')
|
||||
self.assertIn('36KG = 144元',self.ask('再加1箱,每箱5KG'))
|
||||
|
||||
def test_declared_new_totals_are_validated_after_a_carton_change(self):
|
||||
self.ask('美国10箱每箱20KG,义乌美西普船海派报价')
|
||||
self.assertIn('240KG = 960元',self.ask('再加2箱每箱20KG,总重240KG'))
|
||||
self.assertIn('增减箱数推算的总重',self.ask('再加1箱每箱20KG,总重300KG'))
|
||||
self.assertNotIn('KG =',self.ask('换成华南'))
|
||||
self.assertIn('300KG',self.ask('总重300KG,共13箱'))
|
||||
|
||||
def test_heterogeneous_carton_removal_requires_the_updated_breakdown(self):
|
||||
self.ask('美国1箱10KG,1箱30KG,义乌美西普船海派报价')
|
||||
self.assertIn('不同箱规',self.ask('减少1箱'))
|
||||
self.assertNotIn('KG =',self.ask('具体多少钱'))
|
||||
self.assertIn('30KG = 120元',self.ask('现在1箱30KG'))
|
||||
|
||||
def test_rejected_services_remain_excluded_until_explicitly_selected_again(self):
|
||||
self.ask('美国1000KG义乌美东海运最便宜报价')
|
||||
text=self.ask('不走普船了,改成美森正班')
|
||||
self.assertIn('1000KG = 10000元',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
text=self.ask('不要美森,走普船')
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
self.assertNotIn('美森',text)
|
||||
self.ask('不走普船')
|
||||
text=self.ask('改成华南')
|
||||
self.assertNotIn('普船海派',text)
|
||||
self.assertNotIn('美森',text)
|
||||
self.assertIn('合德海派',text)
|
||||
self.assertIn('普船海派',self.ask('那还是普船吧'))
|
||||
self.assertIn('美森正班海派',self.ask('全部渠道报价'))
|
||||
|
||||
def test_tax_corrections_and_pending_conflicts_are_not_reversed(self):
|
||||
self.ask('美国1000KG义乌美东海派报价')
|
||||
self.assertIn('1000KG = 6000元',self.ask('不要不包税,我要包税'))
|
||||
self.assertIn('没有匹配',self.ask('不是包税,是不包税'))
|
||||
self.assertIn('1000KG = 6000元',self.ask('那还是包税吧'))
|
||||
self.assertIn('1000KG = 6000元',self.ask('不是包税吗?'))
|
||||
self.assertIn('明确选择包税',self.ask('不要包税,也不要不包税'))
|
||||
self.assertNotIn('KG =',self.ask('换成华南'))
|
||||
self.assertIn('1000KG = 6000元',self.ask('要包税'))
|
||||
|
||||
def test_new_batch_with_rejections_resets_old_shipping_conditions(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
text=self.ask('新的一批美国30KG海派报价,不走普船')
|
||||
self.assertIn('美国 30KG',text)
|
||||
self.assertIn('华南',text)
|
||||
self.assertIn('美西',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
|
||||
def test_calendar_questions_preserve_route_and_request_the_missing_dates(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
for question in ('什么时候能到','大概哪天能收到'):
|
||||
text=self.ask(question)
|
||||
self.assertIn('25-30天左右提取',text)
|
||||
self.assertIn('预计开船日期',text)
|
||||
text=self.ask('快一点呢')
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
text=self.ask('那比慢的贵多少')
|
||||
self.assertIn('运费差额为4000元',text)
|
||||
|
||||
def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self):
|
||||
self.ask('美国1000KG海运最快和最慢报价')
|
||||
self.assertIn('1000KG',self.ask('这个多少钱'))
|
||||
self.assertIn('请指定方案名称',self.ask('第二个方案呢'))
|
||||
self.assertIn('1000KG = 4000元',self.ask('便宜的那个'))
|
||||
self.assertIn('1000KG = 4000元',self.ask('一共呢'))
|
||||
|
||||
def test_tied_speed_costs_produce_a_range_without_mixing_tax_or_units(self):
|
||||
from logistics_chat.quote_comparison import speed_difference_lines
|
||||
from logistics_chat.quote_data import load_book
|
||||
from logistics_chat.quote_intent import parse_intent
|
||||
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'service':name,'price':price,'eta':eta} for name,price,eta in (
|
||||
('快线A','8','12-15天'),('快线B','10','12-15天'),
|
||||
('慢线A','5','30-35天'),('慢线B','6','30-35天'))]
|
||||
rates.append({**base,'service':'自税线','tax':'excluded','price':'999','eta':'20天'})
|
||||
rates.append({**base,'service':'按方线','unit':'cbm','price':'999','eta':'20天'})
|
||||
with self.app.app_context():
|
||||
book=load_book()
|
||||
intent=parse_intent('美国1000KG义乌美东海派报价',set())
|
||||
lines=speed_difference_lines(intent,book,rates,None)
|
||||
self.assertTrue(any(' · 包税 · ' in line and '2000至5000元' in line for line in lines),lines)
|
||||
self.assertTrue(any('不包税' in line and '差额为0元' in line for line in lines),lines)
|
||||
self.assertEqual(len(lines),2)
|
||||
self.assertEqual(speed_difference_lines(intent,book,[{**rates[0],'eta':'未提供时效'}],None),[])
|
||||
|
||||
def test_polite_relative_edits_and_unrecognized_adjustments_do_not_replace_the_total(self):
|
||||
self.ask('美国1000KG义乌美东普船海派报价')
|
||||
self.assertIn('1100KG = 6600元',self.ask('那我再加一百公斤'))
|
||||
self.assertIn('900KG = 5400元',self.ask('请帮我减少200KG'))
|
||||
self.assertIn('调整后的总重',self.ask('这批货后面增加100KG'))
|
||||
self.assertNotIn('KG =',self.ask('具体多少钱'))
|
||||
|
||||
def test_difference_uses_the_displayed_two_decimal_costs(self):
|
||||
from logistics_chat.quote_comparison import speed_difference_lines
|
||||
from logistics_chat.quote_data import load_book
|
||||
from logistics_chat.quote_intent import parse_intent
|
||||
base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'service':'快线','price':'6.4','eta':'12天'},
|
||||
{**base,'service':'慢线','price':'4.3','eta':'30天'}]
|
||||
with self.app.app_context():
|
||||
intent=parse_intent('美国12.555KG义乌美东海派报价',set())
|
||||
text='\n'.join(speed_difference_lines(intent,load_book(),rates,None))
|
||||
self.assertIn('差额为26.36元',text)
|
||||
|
||||
def test_difference_shows_both_speeds_for_each_origin_used_in_the_calculation(self):
|
||||
with self.app.app_context():
|
||||
c=db.connect()
|
||||
c.execute("UPDATE quote_rates SET eta='船开后16天左右' WHERE service='美森正班海派' AND origin='华南'")
|
||||
c.commit();c.close()
|
||||
text=self.ask('美国1000KG美东海派最快最慢差价')
|
||||
self.assertIn('美森正班海派 · 义乌',text)
|
||||
self.assertIn('美森正班海派 · 华南',text)
|
||||
self.assertEqual(text.count('运费差额为4000元'),2)
|
||||
|
||||
def test_volume_increments_keep_the_confirmed_total_and_units(self):
|
||||
self.ask('美国2CBM义乌美东普船海派报价')
|
||||
text=self.ask('再加1方')
|
||||
self.assertIn('总体积为3CBM',text)
|
||||
self.assertIn('3000元',text)
|
||||
text=self.ask('总体积减少0.5CBM')
|
||||
self.assertIn('总体积为2.5CBM',text)
|
||||
self.assertIn('2500元',text)
|
||||
|
||||
def test_carton_changes_with_an_aggregate_volume_wait_for_the_updated_volume(self):
|
||||
self.ask('美国2箱每箱20KG,1CBM,义乌美东普船海派报价')
|
||||
self.assertIn('调整后的总体积',self.ask('再加1箱每箱20KG'))
|
||||
text=self.ask('总重60KG')
|
||||
self.assertIn('调整后的总体积',text)
|
||||
self.assertNotIn('KG =',text)
|
||||
text=self.ask('总体积1.5CBM')
|
||||
self.assertIn('1500元',text)
|
||||
|
||||
def test_unrecognized_volume_increment_is_clarified_without_using_it_as_a_new_total(self):
|
||||
self.ask('美国2CBM义乌美东普船海派报价')
|
||||
text=self.ask('这批货后面增加1方')
|
||||
self.assertIn('调整后的总体积',text)
|
||||
self.assertNotIn('元/KG',text)
|
||||
self.assertIn('3000元',self.ask('总体积3CBM'))
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Fast/slow comparisons preserve shipping conditions and comparable rate groups."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app, db
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from logistics_chat.quote_engine import select_timed_rates
|
||||
from test_quotes import fixture_snapshot, frames
|
||||
|
||||
|
||||
def timing_snapshot():
|
||||
book=fixture_snapshot()
|
||||
rates=[]
|
||||
for origin in ('义乌','华南'):
|
||||
for zone,offset in (('美西',0),('美中',1),('美东',2)):
|
||||
for service,price,eta in (('美森正班海派',8,'船开后15天左右'),
|
||||
('合德海派',6,'船开后20-22天左右'),
|
||||
('普船海派',4,'船开后25-30天左右')):
|
||||
rates.append({'service':service,'kind':'parcel','origin':origin,'zone':zone,
|
||||
'unit':'kg','tax':'included','min_weight':'12','price':str(price+offset),
|
||||
'eta':eta,'destinations':[],'source_sheet':'合成时效','source_cell':'A1'})
|
||||
book['rates']=rates
|
||||
return book
|
||||
|
||||
|
||||
class QuoteTimingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'2'*64})
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
import_snapshot(connection,timing_snapshot(),activate=True)
|
||||
connection.commit();connection.close()
|
||||
|
||||
def ask(self,question,cid=None):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(events[-1],{'d':1},events)
|
||||
return events[0]['t']
|
||||
|
||||
def test_fastest_and_slowest_show_each_region_origin_and_the_correct_amount(self):
|
||||
text=self.ask('我想发一批货,一吨发发到美国走海运,最快的和最慢的价格分别是多少?')
|
||||
fast,slow=text.split('最慢参考方案')
|
||||
self.assertIn('最快参考方案',fast)
|
||||
self.assertIn('美西 8元/KG,1000KG = 8000元',fast)
|
||||
self.assertIn('美东 10元/KG,1000KG = 10000元',fast)
|
||||
self.assertIn('美森正班海派 · 华南',fast)
|
||||
self.assertNotIn('普船海派',fast)
|
||||
self.assertIn('美西 4元/KG,1000KG = 4000元',slow)
|
||||
self.assertIn('美东 6元/KG,1000KG = 6000元',slow)
|
||||
self.assertIn('船开后25-30天左右提取',slow)
|
||||
self.assertNotIn('合德海派',text)
|
||||
|
||||
def test_short_followups_keep_weight_origin_zone_and_selected_comparison(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('1000KG到美国走海运,最快和最慢价格分别多少',cid)
|
||||
for question in ('慢的呢?','义乌交仓','美东呢','请给我具体报价'):
|
||||
text=self.ask(question,cid)
|
||||
self.assertIn('美国 1000KG',text)
|
||||
self.assertIn('最慢参考方案',text)
|
||||
self.assertNotIn('最快参考方案',text)
|
||||
self.assertNotIn('华南',text)
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
text=self.ask('50KG呢',cid)
|
||||
self.assertIn('50KG = 300元',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
text=self.ask('全部渠道报价',cid)
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertIn('合德海派',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
text=self.ask('新的一批100KG发美国海运报价',cid)
|
||||
self.assertIn('美国 100KG',text)
|
||||
self.assertIn('华南',text)
|
||||
self.assertNotIn('最慢参考方案',text)
|
||||
|
||||
def test_numeric_timing_groups_keep_destinations_tax_and_service_kinds_separate(self):
|
||||
base={'zone':'美西','kind':'parcel','unit':'kg','tax':'included'}
|
||||
rates=[{**base,'eta':'10-35天','service':'wide'},
|
||||
{**base,'eta':'15-25天','service':'narrow'},
|
||||
{**base,'zone':'美东','eta':'40-45天','service':'east'},
|
||||
{**base,'tax':'excluded','eta':'20天','service':'excluded'},
|
||||
{**base,'kind':'truck','eta':'50天','service':'truck'},
|
||||
{**base,'eta':'未提供时效','service':'unknown'}]
|
||||
self.assertEqual({r['service'] for r in select_timed_rates(rates)},
|
||||
{'wide','east','excluded','truck'})
|
||||
self.assertEqual({r['service'] for r in select_timed_rates(rates,slowest=True)},
|
||||
{'wide','east','excluded','truck'})
|
||||
self.assertEqual(select_timed_rates([{**base,'eta':'若干天'}]),[])
|
||||
|
||||
def test_english_comparison_keeps_numbers_and_renders_both_speeds(self):
|
||||
text=self.ask('1000kg to USA by sea, fastest and slowest prices please')
|
||||
self.assertIn('Fastest reference options',text)
|
||||
self.assertIn('Slowest reference options',text)
|
||||
self.assertIn('1000KG = 4000 CNY',text)
|
||||
|
||||
def test_different_regional_times_are_rendered_on_their_own_price_lines(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET eta='船开后18天左右' WHERE service='美森正班海派' AND zone='美东'")
|
||||
connection.commit();connection.close()
|
||||
text=self.ask('美国1000KG海运最快价格,义乌')
|
||||
self.assertIn('美东 10元/KG,1000KG = 10000元(船开后18天左右提取)',text)
|
||||
self.assertIn('美西 8元/KG,1000KG = 8000元',text)
|
||||
|
||||
def test_unknown_times_do_not_become_a_fastest_or_slowest_promise(self):
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
connection.execute("UPDATE quote_rates SET eta='若干天'")
|
||||
connection.commit();connection.close()
|
||||
text=self.ask('美国1000KG海运最快和最慢价格')
|
||||
self.assertIn('最快参考方案暂时无法确定',text)
|
||||
self.assertIn('最慢参考方案暂时无法确定',text)
|
||||
self.assertNotIn('1000KG =',text)
|
||||
|
||||
def test_selected_route_survives_total_calculation_time_and_tax_questions(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国1000KG义乌美东海运,便宜的报价',cid)
|
||||
for question in ('那一共多少钱','怎么算出来的'):
|
||||
text=self.ask(question,cid)
|
||||
self.assertIn('普船海派',text)
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
self.assertNotIn('美森正班海派',text)
|
||||
text=self.ask('多快能到',cid)
|
||||
self.assertIn('普船海派',text)
|
||||
self.assertIn('船开后25-30天左右提取',text)
|
||||
self.assertNotIn('美森正班海派',text)
|
||||
self.assertIn('标注包税',self.ask('含税吗',cid))
|
||||
text=self.ask('具体多少',cid)
|
||||
self.assertIn('1000KG = 6000元',text)
|
||||
self.assertNotIn('美森正班海派',text)
|
||||
text=self.ask('全部渠道报价',cid)
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertIn('普船海派',text)
|
||||
|
||||
def test_polite_acknowledgements_keep_quote_conditions_and_selection(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国30KG义乌美西最快海派报价',cid)
|
||||
for question in ('谢谢','好的,谢谢您','OK'):
|
||||
self.ask(question,cid)
|
||||
text=self.ask('改成50KG呢',cid)
|
||||
self.assertIn('美森正班海派 · 义乌',text)
|
||||
self.assertIn('50KG = 400元',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
text=self.ask('所有箱都是普通货',cid)
|
||||
self.assertIn('最快参考方案',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
|
||||
def test_missing_weight_unit_stays_pending_until_an_explicit_unit_is_supplied(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国1000KG义乌美西海运最快报价',cid)
|
||||
for question in ('重量改成800,其他不变','换成华南','谢谢','美东呢'):
|
||||
text=self.ask(question,cid)
|
||||
if question!='谢谢':
|
||||
self.assertIn('单位',text)
|
||||
self.assertNotIn('1000KG =',text)
|
||||
text=self.ask('单位是KG',cid)
|
||||
self.assertIn('美森正班海派 · 华南',text)
|
||||
self.assertIn('800KG = 8000元',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
self.assertIn('800KG = 8000元',self.ask('具体多少',cid))
|
||||
|
||||
def test_quote_resume_is_explicit_and_survives_later_field_edits(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国30KG义乌美西最快报价',cid)
|
||||
self.ask('我有多少个订单',cid)
|
||||
text=self.ask('50KG呢',cid)
|
||||
self.assertNotIn('美森正班海派',text)
|
||||
text=self.ask('回到刚才的报价',cid)
|
||||
self.assertIn('30KG = 240元',text)
|
||||
self.assertNotIn('50KG',text)
|
||||
text=self.ask('换成华南',cid)
|
||||
self.assertIn('美森正班海派 · 华南',text)
|
||||
self.assertIn('30KG = 240元',text)
|
||||
|
||||
def test_resume_does_not_recover_us_conditions_across_a_new_country_quote(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国30KG义乌海运报价',cid)
|
||||
with self.app.app_context():
|
||||
db.save_message(cid,'user','新的一票100KG发德国海运报价')
|
||||
db.save_message(cid,'assistant','请核对对应目的地的报价。')
|
||||
self.ask('我有多少个订单',cid)
|
||||
text=self.ask('回到刚才的报价',cid)
|
||||
self.assertIn('目的国家',text)
|
||||
self.assertNotIn('美国 30KG',text)
|
||||
self.assertNotIn('元/KG',text)
|
||||
|
||||
def test_new_quote_with_a_topic_switch_becomes_the_next_followup_root(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国30KG义乌美西最快报价',cid)
|
||||
self.ask('换个话题,美国50KG华南美东海运报价',cid)
|
||||
text=self.ask('最慢的呢',cid)
|
||||
self.assertIn('普船海派 · 华南',text)
|
||||
self.assertIn('50KG = 300元',text)
|
||||
self.assertNotIn('30KG',text)
|
||||
self.ask('另外问美国海运报价',cid)
|
||||
text=self.ask('20KG呢',cid)
|
||||
self.assertIn('美国 20KG',text)
|
||||
self.assertIn('义乌',text)
|
||||
self.assertNotIn('50KG',text)
|
||||
|
||||
def test_explicit_selection_and_business_question_are_retained_independently(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国1000KG义乌美东海运报价',cid)
|
||||
self.assertIn('标注包税',self.ask('最快的含税吗',cid))
|
||||
text=self.ask('那一共多少钱',cid)
|
||||
self.assertIn('1000KG = 10000元',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
text=self.ask('最快多久能提取',cid)
|
||||
self.assertIn('15天左右提取',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
|
||||
def test_missing_weight_units_before_a_destination_do_not_reuse_the_old_weight(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国1000KG义乌美西最快海运报价',cid)
|
||||
self.assertIn('单位',self.ask('重量改成800美东,其他不变',cid))
|
||||
text=self.ask('KG',cid)
|
||||
self.assertIn('800KG = 8000元',text)
|
||||
self.assertNotIn('1000KG',text)
|
||||
|
||||
def test_all_channels_clears_the_named_service_and_keeps_origin_destination_weight(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('美国1000KG义乌美东普船海派最便宜报价',cid)
|
||||
text=self.ask('全部渠道报价',cid)
|
||||
self.assertIn('美森正班海派',text)
|
||||
self.assertIn('合德海派',text)
|
||||
self.assertIn('普船海派',text)
|
||||
self.assertIn('1000KG = 10000元',text)
|
||||
self.assertNotIn('华南',text)
|
||||
self.assertNotIn('美西 ',text)
|
||||
Reference in New Issue
Block a user