49 changed files with 2903 additions and 306 deletions
+2
View File
@@ -3,3 +3,5 @@
# 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=
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
ORDER_DATA_TENANT_ID=
+5 -4
View File
@@ -10,10 +10,11 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
| Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` |
| Human support | `logistics_chat/human_service.py`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView`; `admin_tools.js`: notes, transfer, search; `support.js`: emoji, notifications, rating |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` |
| Deployment | `Jenkinsfile`, `Dockerfile`; `deploy/gray_config.py`: `GrayConfig`; publish/activate/TLS scripts; `docs/deployment.md` |
@@ -51,7 +52,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` when Node.js is available; no npm dependencies.
Tests run `tests/frontend/admin.test.cjs` and `chat.test.cjs` when Node.js is available; no npm dependencies.
## Commit & Pull Request Guidelines
@@ -59,4 +60,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
## Security & Configuration Tips
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Admin authentication remains incomplete; protect all acceptance routes through Nginx.
+1 -1
View File
@@ -31,7 +31,7 @@ RUN python -m pytest -q -p no:cacheprovider
FROM ${PYTHON_BASE_IMAGE} AS certbot
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
RUN python -m pip install --no-cache-dir certbot==5.9.0
RUN python -m pip install --no-cache-dir certbot==5.8.0
ENTRYPOINT ["certbot"]
FROM runtime AS production
Vendored
+1 -1
View File
@@ -23,7 +23,7 @@ pipeline {
}
sh '''set -eu
for file in deploy/*.sh; do bash -n "$file"; done
node --test tests/frontend/admin.test.cjs
node --test tests/frontend/*.test.cjs
'''
}
}
+28 -6
View File
@@ -8,11 +8,12 @@
- **知识库优先**:优先使用本地知识库回答,减少模型编造
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
- **运单号查询**:发送 8~20 位运单号,返回轨迹卡片;先展示最近节点,可展开全部轨迹
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
- **会话恢复**:连接中断保留已收到的正文;切换话题取消旧附件,等待建会话时保留新草稿;历史加载失败保留已有列表
- **深色模式**:右上角切换,偏好本地保存
- **响应式**:窄屏下侧栏自动变为抽屉
- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件
@@ -23,6 +24,8 @@
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
@@ -132,7 +135,8 @@ baiyue-logistics-chat/
│ ├── knowledge.py 知识与关键词检索
│ ├── pricing.py 运费解析与计算
│ ├── rates_extra.py 扩展报价唯一数据源
│ ├── tracking.py 模拟轨迹与卡片协议
│ ├── tracking.py 卡片协议与旧演示函数兼容
│ ├── order_data.py 本地订单测试表、精确查询与快照导入
│ ├── ai_client.py 模型 HTTP、压缩和追问
│ ├── chat_service.py 问答与 SSE 编排
│ ├── history_service.py 会话 CRUD
@@ -201,7 +205,7 @@ py -3 scripts/close_active_sessions.py
|---|---|---|
| GET | `/` | 客户聊天页面 |
| GET / POST | `/stream_chat` | SSE 流式对话;POST JSON 接收 `msg`、`conv_id` |
| POST | `/api/new_chat` | 新建会话,返回 `id` |
| POST | `/api/new_chat` | 新建会话并保存欢迎语,返回 `id`、`welcome` |
| GET | `/api/get_history` | 会话列表 |
| GET | `/api/get_messages?conv_id=` | 单个会话的消息 |
| DELETE | `/api/del_history?id=` | 删除单个会话和消息 |
@@ -212,10 +216,18 @@ py -3 scripts/close_active_sessions.py
| POST | `/api/human/send` | 发送人工消息 |
| GET | `/api/human/poll` | 轮询人工消息 |
| POST | `/api/human/close` | 关闭人工会话 |
| POST | `/api/human/transfer` | 将接待中的会话转回排队列表 |
| POST | `/api/human/note` | 保存会话备注 |
| POST | `/api/human/recall` | 按 `mid` 撤回客服消息 |
| POST | `/api/rate` | 提交人工服务评价(`sid`、1–5 的 `stars`、`comment`) |
| GET | `/api/agents/list` | 客服列表,不包含密码 |
| GET | `/api/search_chat?q=` | 搜索历史消息,最多返回 50 条 |
| GET | `/admin` | 客服登录页面 |
| POST | `/admin/login` | 校验客服账号密码 |
| GET | `/admin/dashboard` | 客服工作台 |
| GET | `/api/export_chat` | 导出聊天记录为 CSV |
| GET | `/api/export_chat` | 导出 Excel;`?format=csv` 保留 CSV 导出 |
人工客服每天 8:00–22:00 接受新申请,由服务端校验时间;其余时间可提交离线留言。客服工作台保留白色界面,转接后由其他客服在排队列表主动接入。页面的铃铛按钮可开启浏览器通知,页面切到后台时提示排队或新消息;实际通知能力取决于浏览器权限。
SSE 每帧为一条 JSON,主要事件包括:
@@ -230,13 +242,23 @@ data: {"d": 1}
## 数据库与运行数据
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`agents`、`orders` 和 `tickets` 八张表,不自动创建客服账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
订单测试数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改。聊天每次重新查表;这份副本不会自动同步灰度业务库。没有新增管理页面和增删改接口。内部验收可查询已导入的测试样本;配置 `ORDER_DATA_TENANT_ID` 可以在服务器侧固定租户范围,不能将其视为客户登录授权。
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
```powershell
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
```
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
## 当前限制
- 运单轨迹为模拟数据,尚未接入真实物流查询接口。
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史中的模拟轨迹保留演示标识。
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
+23 -4
View File
@@ -12,7 +12,7 @@ release=${2:?}
# Parse a small allowlist of non-secret release fields; never source a credential file.
while IFS='=' read -r name value; do
case "$name" in
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY) ;;
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;;
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
esac
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
@@ -24,7 +24,9 @@ done < "$release/release.env"
[[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2
[[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
@@ -41,7 +43,8 @@ if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOS
fi
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.htpasswd"
[[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2
auth="$CHAT_AUTH_DIR/access.htpasswd"
if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then
echo 'The proxy file is not owned by this application.' >&2
exit 2
@@ -53,6 +56,13 @@ for candidate in "$CHAT_PROXY_DIR/"*.conf; do
exit 2
fi
done
# The Nginx config directory may be root-only (for example BaoTa). Keep this
# application's password hash in a separate worker-readable owned directory.
if [[ -e "$CHAT_AUTH_DIR" ]]; then
[[ -f "$CHAT_AUTH_DIR/.project" && "$(cat "$CHAT_AUTH_DIR/.project")" == baiyue-logistics-chat-gray ]] || exit 2
fi
install -d -m 755 "$CHAT_AUTH_DIR"
printf 'baiyue-logistics-chat-gray\n' > "$CHAT_AUTH_DIR/.project"
previous=''
if [[ -f "$root/current-release" ]]; then
previous=$(cat "$root/current-release")
@@ -68,6 +78,8 @@ proxy_changed=0
rollback() {
result=$?
trap - ERR
# ERR is inherited by command substitutions; only the parent may roll back.
if ((BASH_SUBSHELL)); then exit "$result"; fi
set +e
if ((proxy_changed)); then
if [[ -f "$release/previous-site.conf" ]]; then cp -p "$release/previous-site.conf" "$site"; else rm -f -- "$site"; fi
@@ -100,7 +112,14 @@ install -m 644 "$release/site.conf" "$site"
nginx -t
nginx -s reload
for path in / /api/export_chat /api/get_history; do
code=$(curl --silent --show-error --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path")
code=''
# A successful reload signal precedes the new workers accepting connections.
for attempt in {1..10}; do
if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then
[[ "$code" != 401 ]] || break
fi
sleep 1
done
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
done
printf '%s\n' "$release" > "$root/.current-release.new"
+2
View File
@@ -6,6 +6,8 @@ services:
pull_policy: never
restart: unless-stopped
init: true
environment:
TZ: Asia/Shanghai
ports:
- "127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"
env_file:
+3 -1
View File
@@ -29,6 +29,7 @@ class GrayConfig:
image: str
image_id: str
certbot_image_id: str
auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy'
@classmethod
def from_environment(cls, environment):
@@ -70,6 +71,7 @@ class GrayConfig:
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key,
'CHAT_AUTH_DIR': self.auth_dir,
}
return ''.join(f'{key}={value}\n' for key, value in fields.items())
@@ -91,7 +93,7 @@ server {{
ssl_certificate_key {self.private_key};
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "Baiyue gray acceptance";
auth_basic_user_file {self.proxy_dir}/.baiyue-logistics-chat.htpasswd;
auth_basic_user_file {self.auth_dir}/access.htpasswd;
client_max_body_size 12m;
location / {{
proxy_pass http://127.0.0.1:{self.port};
+3 -1
View File
@@ -18,7 +18,9 @@ done < "$release/release.env"
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CERTBOT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded certificate image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
+33 -3
View File
@@ -40,7 +40,7 @@ release=$2
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* ]] || exit 2
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
for program in docker nginx openssl curl ss systemctl; do command -v "$program" >/dev/null; done
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
echo 'Refusing to reuse an unowned deployment directory.' >&2
exit 2
@@ -55,8 +55,38 @@ chown 10001:10001 "$root/data" "$root/logs"
install -d -m 700 "$release"
PREPARE
# Transport only tested images built without runtime credentials.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker load'"
# Verify the exact exported archive before loading. Classic and containerd image
# stores can report different IDs for the same archive, so retain target IDs too.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
set -Eeuo pipefail
umask 077
root=$1
release=$2
checksum=$3
app_image=$4
certbot_image=$5
source_commit=$6
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
gzip -dc "$release/images.tar.gz" | docker load
for image in "$app_image" "$certbot_image"; do
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
done
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
rm -f -- "$release/images.tar.gz"
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
LOAD
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
run_ssh bash "$release/activate.sh" "$root" "$release"
+19 -11
View File
@@ -26,13 +26,14 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
| 知识检索 | `logistics_chat/knowledge.py`:`knowledge_list`、`search_knowledge` | [knowledge.md](knowledge.md) 与代码同步,程序不读取该文档 |
| 运费与附加费 | `logistics_chat/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
| 运单轨迹 | `logistics_chat/tracking.py`:`extract_tracking_no`、`build_track`、`CARD_PREFIX`、`card_to_text` | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;模拟节点时间取当前时刻,回归对照需固定时钟 |
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史模拟轨迹标为演示数据 |
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`,不让模型拼 SQL |
| 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` |
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
| 附件 | `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`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证异步交互 |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_dev_integration.py`、`test_config.py`、`test_deployment.py`、`test_gray_config.py`、`test_gray_activation.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs`、`chat.test.cjs` | Linux 回退模拟基础设施、实际操作临时文件;模型、数据库及上传隔离;Node 验证草稿、异步切换、转接、撤回及评价;浏览器使用临时测试库 |
除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。
@@ -49,11 +50,12 @@ logistics_chat/
├── pricing.py # 运费规则、解析、换算、附加费说明
├── rates_extra.py # 扩展报价数据
├── tracking.py # 单号识别、模拟轨迹、卡片协议
├── order_data.py # 可编辑订单测试表、精确查询、快照导入与卡片事实
├── ai_client.py # 模型 HTTP、压缩与追问
├── chat_service.py # 问答编排与 SSE 帧
├── history_service.py # 历史会话 CRUD
├── human_service.py # 人工状态与消息
├── business_service.py # 反馈、统计、登录、订单、留言、CSV
├── business_service.py # 反馈、评价、统计、登录、订单、留言、历史搜索、Excel/CSV
├── file_service.py # 附件校验、保存、清理
├── hooks.py # 限流及 HTML/SSE 缓存头
├── runtime.py # Waitress / Flask 启动
@@ -86,6 +88,7 @@ tests/ # 按业务模块组织的回归
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
## 后续结构调整顺序
@@ -101,13 +104,18 @@ tests/ # 按业务模块组织的回归
## 必须保留的接口与数据约定
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent`。
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 用 `is_shipping_followup` 识别渠道、重量、尺寸、单独目的地回复及重试,保留报价条件;新订单、另外一票、理赔及新的完整问题形成回溯边界,不继承上一票尺寸。尺寸支持带小数和空格,换算单位后计价。中英混写的 UK/US 按 ASCII 边界识别;目的地未确认时先询问目的国家,不默认给出欧洲报价。英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
- 聊天请求的会话编号仅接受 SQLite 整数范围内的 ASCII 十进制整数或空值;字符串转换前限制长度,非法 Unicode 数字和超长数字返回 SSE 输入错误,不进入模型调用。
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
## 验证与维护
+6 -2
View File
@@ -4,6 +4,8 @@
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
## Jenkins 任务
任务名为 `baiyue-logistics-chat-gray`。定义选择 Pipeline script from SCM,本项目 Git 地址为仓库,分支字段为 `${SOURCE_REF}`,脚本路径为 `Jenkinsfile`。公开仓库可匿名读取;转为私有后配置只读 SCM 凭据。
@@ -35,11 +37,11 @@
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名、源提交和镜像 ID,生成私有配置并发送镜像及发布文件。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,接收主机再次核验镜像 ID,不依赖 `latest`。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
## 数据与失败回退
@@ -56,6 +58,8 @@
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
+8
View File
@@ -29,6 +29,7 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
"""回答超长时,让模型压缩到规定字数内;失败则原样返回"""
if len(text) <= limit:
return text
r = None
try:
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': (EN_COMPRESS_RULE if has_chinese(text) is False else '') + f'你是文字精简助手。把用户给的内容压缩到 {limit} 字以内(必须严格满足,超出即为不合格),必须保留所有数字、金额、时效和渠道名称,删除重复表述、客套话和次要说明,结构保持不变。'}, {'role': 'user', 'content': text}], 'stream': False, 'max_tokens': 800}
r = post_completion(body, timeout=(5, 30))
@@ -39,6 +40,9 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
print(f'[压缩失败] 状态码 {r.status_code}')
except Exception as e:
print(f'[压缩异常] {e}')
finally:
if r is not None:
r.close()
return text
@@ -46,6 +50,7 @@ def gen_followups(user_msg, answer_text):
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
if not get_setting('DEEPSEEK_API_KEY'):
return []
r = None
try:
body = {'model': get_setting('MODEL_NAME'), 'messages': [{'role': 'system', 'content': '你是物流客服助手。根据用户问题和你的回答,生成3个客户最可能接着问的简短问题(每个不超过20字),只输出JSON字符串数组,不要任何解释。'}, {'role': 'user', 'content': f'用户问:{user_msg}\n你的回答:{answer_text[:500]}\n\n请生成3个追问:'}], 'stream': False, 'max_tokens': 200}
r = post_completion(body, timeout=(5, 15))
@@ -58,4 +63,7 @@ def gen_followups(user_msg, answer_text):
return [str(x).strip() for x in arr if str(x).strip()][:3]
except Exception:
pass
finally:
if r is not None:
r.close()
return []
+68 -8
View File
@@ -3,6 +3,7 @@
from datetime import datetime
from .db import connect, now_str
from .tracking import card_to_text
def feedback(data):
@@ -22,8 +23,10 @@ def admin_stats():
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
today = datetime.now().strftime('%Y-%m-%d')
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
conn.close()
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv}
return {'total_conv': total_conv, 'total_msg': total_msg, 'good': good, 'bad': bad, 'today_conv': today_conv,
'rating_avg': round(rating_avg, 1), 'rating_count': rating_count}
def my_orders(phone):
@@ -61,15 +64,72 @@ def admin_login(data):
return ({'ok': False}, 401)
def export_chat():
def agents_list():
conn = connect()
# rowid also works with the legacy agents table that has an explicit INTEGER id.
rows = conn.execute('SELECT rowid, username, name FROM agents ORDER BY rowid').fetchall()
conn.close()
return [{'id': row[0], 'username': row[1], 'name': row[2]} for row in rows]
def rate_chat(data):
sid, stars = data.get('sid'), data.get('stars', 0)
comment = data.get('comment', '')
if not sid or type(stars) is not int or not 1 <= stars <= 5 or not isinstance(comment, str):
return ({'ok': False}, 400)
conn = connect()
if not conn.execute('SELECT 1 FROM human_sessions WHERE id=?', (sid,)).fetchone():
conn.close()
return ({'ok': False}, 404)
conn.execute('INSERT INTO ratings(sid, stars, comment, ctime) VALUES(?,?,?,?)',
(sid, stars, comment, datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
conn.commit()
conn.close()
return {'ok': True}
def search_chat(keyword):
if not keyword:
return {'results': []}
conn = connect()
rows = conn.execute('''SELECT h.title, m.role, m.content, m.ctime FROM messages m
JOIN chat_history h ON m.conv_id=h.id WHERE m.content LIKE ? ORDER BY m.id DESC LIMIT 50''',
('%' + keyword + '%',)).fetchall()
conn.close()
return [{'title': row[0], 'role': row[1], 'content': card_to_text(row[2]), 'ctime': row[3]} for row in rows]
def export_chat(format='xlsx'):
conn = connect()
rows = conn.execute('SELECT h.title, m.role, m.content, m.ctime FROM messages m JOIN chat_history h ON m.conv_id=h.id ORDER BY m.id').fetchall()
conn.close()
import csv
import io
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(['标题', '角色', '内容', '时间'])
for r in rows:
w.writerow([r[0], r[1], r[2] or '', r[3]])
if format == 'csv':
import csv
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(['标题', '角色', '内容', '时间'])
for r in rows:
w.writerow([r[0], r[1], r[2] or '', r[3]])
return buf.getvalue()
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
workbook = Workbook()
sheet = workbook.active
sheet.title = '聊天记录'
sheet.append(['对话标题', '角色', '内容', '时间'])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='315FD5')
cell.alignment = Alignment(horizontal='center')
roles = {'user': '客户', 'assistant': '客服', 'system': '系统'}
for title, role, content, ctime in rows:
sheet.append([title, roles.get(role, role), content or '', ctime])
for cell in sheet[sheet.max_row]:
cell.data_type = 's'
for column, width in zip('ABCD', (20, 8, 80, 20)):
sheet.column_dimensions[column].width = width
buf = io.BytesIO()
workbook.save(buf)
return buf.getvalue()
+108 -28
View File
@@ -9,8 +9,10 @@ from .config import get_setting
from .db import connect, last_user_question, load_context, save_message
from .knowledge import search_knowledge
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island)
from .tracking import CARD_PREFIX, extract_tracking_no, build_track
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island,
is_shipping_destination_reply)
from .tracking import CARD_PREFIX
from .order_data import answer_query, extract_reference
@@ -20,45 +22,108 @@ def sse(payload):
return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n'
QUOTE_FOLLOWUP_PREFIX = r'^(?:那(?:么)?|换成|改成|改为|如果|还是|再算|同样)'
def is_shipping_followup(message, intent=None):
"""Recognize bounded quote edits without carrying facts across new topics."""
if re.search(r'新(?:的)?一(?:票|批)|另(?:一|外一?)(?:票|批)|新(?:的)?(?:货|订单)|换个话题|理赔|赔偿|破损|丢失|签收|退件|退款|投诉', message):
return False
intent = intent if intent is not None else parse_shipping_intent(message)
text = message.strip().strip(',,。.!!??、:: ')
prefix = re.match(QUOTE_FOLLOWUP_PREFIX, text)
if prefix and any(intent.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
return True
if intent['region'] is not None and is_shipping_destination_reply(text):
return True
short = re.sub(QUOTE_FOLLOWUP_PREFIX, '', text, count=1).strip()
short = re.sub(r'^(?:请问|请|再问一下)', '', short).strip()
if re.fullmatch(r'(?:海运|海派|空运|空派|铁路|班列|卡航|卡车|sea(?:\s+freight)?|air(?:\s+freight)?|rail|truck)'
r'(?:报价|价格|费用|运费|时效)?(?:呢|怎么样|如何|多少钱|多久|几天)?', short, re.I):
return True
if re.fullmatch(r'(?:价格|费用|运费|时效)(?:呢|是多少|多少|怎么样|如何|多久|几天)?|多少钱', short):
return True
if intent['weight'] is not None and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:kg|公斤|千克|吨|lbs?|tons?|kilograms?)(?:呢)?', short, re.I):
return True
return bool(intent['dims'] and re.fullmatch(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*(?:cm|mm|m|厘米|毫米|米)?(?:呢)?', short, re.I))
def shipping_intent_with_context(message, conv_id):
intent = parse_shipping_intent(message)
intent['dim_unit'] = detect_dim_unit(message)
if conv_id and is_shipping_followup(message, intent):
connection = connect()
try:
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 6", (conv_id,)).fetchall()
finally:
connection.close()
for (previous,) in recent:
if extract_reference(previous):
break
earlier = parse_shipping_intent(previous)
if not any(earlier.get(key) is not None for key in ('weight', 'dims', 'channel', 'region')):
if is_shipping_followup(previous, earlier):
continue
break
for key in ('weight', 'channel', 'region'):
if intent.get(key) is None:
intent[key] = earlier.get(key)
if intent['dims'] is None and earlier['dims']:
intent['dims'] = earlier['dims']
intent['dim_unit'] = detect_dim_unit(previous)
if not is_shipping_followup(previous, earlier):
break
if intent['channel']:
base = intent['channel'].removesuffix('_uk')
intent['channel'] = base + '_uk' if intent['region'] == 'uk' else base
return intent
def create_chat_stream(msg, conv_id):
if not msg:
return (sse({'e': '问题不能为空'}), {})
if '[图片]' in msg and len(msg) < 300:
return (sse({'t': '收到您的图片,请描述一下您的问题。'}), {})
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
if track_no:
card = build_track(track_no)
if conv_id:
connection = connect()
try:
exists = connection.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone()
finally:
connection.close()
if not exists:
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id)
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
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
(ship['channel'] and (is_shipping_followup(msg, ship) or re.search(r'报价|价格|费用|运费|多少钱|\b(?:quote|cost|price)\b', msg, re.I)))):
order_answer = {'t':'请提供目的国家或地区、目的仓或邮编,以便匹配对应费率;当前无法确定报价,可联系人工客服确认。'}
if ship and ship['region'] == 'unsupported' and (ship['weight'] is not None or ship['dims'] or ship['channel'] or
is_shipping_destination_reply(msg) or re.search(r'报价|价格|运费|费用|多少钱', msg)):
order_answer = {'t':'当前报价表尚未包含该目的地的费率,请提供目的仓或邮编、货物品名、件数和尺寸,由人工客服确认报价。'}
if ship and ship['region'] == 'uk' and re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) and not ship['weight'] and not ship['dims']:
order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'}
if order_answer is not None:
def track_stream():
save_message(conv_id, 'user', msg)
yield sse({'card': card})
save_message(conv_id, 'assistant', CARD_PREFIX + json.dumps(card, ensure_ascii=False))
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)
yield sse(order_answer)
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
if not get_setting('DEEPSEEK_API_KEY'):
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
know_text = search_knowledge(msg)
ship = parse_shipping_intent(msg)
ship['dim_unit'] = detect_dim_unit(msg)
freight_text = None
if not ship['weight'] and (not ship['dims']) and ship['channel']:
prev_q = last_user_question(conv_id)
if prev_q:
prev_ship = parse_shipping_intent(prev_q)
if prev_ship['weight'] or prev_ship['dims']:
ship['weight'] = prev_ship['weight']
ship['dims'] = prev_ship['dims']
ship['dim_unit'] = detect_dim_unit(prev_q)
if ship['weight'] or ship['dims']:
if re.search('英国|UK|伦敦|英格兰', msg, re.I):
uk_map = {'sea': 'sea_uk', 'rail': 'rail_uk', 'truck': 'truck_uk', 'air': 'air_uk'}
ship['channel'] = uk_map.get(ship['channel'])
freight_text = freight_reply(ship)
if not ship['channel']:
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
extra_parts = []
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
if cbm_m:
if cbm_m and ship['region'] != 'uk':
cbm = float(cbm_m.group(1))
ch = '海运'
if re.search('卡航|卡车', msg):
@@ -94,7 +159,7 @@ def create_chat_stream(msg, conv_id):
if know_text:
follow_src = prev_q
_c = connect()
_msg_count = _c.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?', (conv_id,)).fetchone()[0]
_msg_count = _c.execute("SELECT COUNT(*) FROM messages WHERE conv_id=? AND role='user'", (conv_id,)).fetchone()[0]
_c.close()
is_first_turn = _msg_count == 0
save_message(conv_id, 'user', msg)
@@ -144,8 +209,11 @@ def create_chat_stream(msg, conv_id):
return
parts = []
interrupted = False
answer_saved = False
try:
got_text = False
completed = False
finish_reason = None
for raw in resp.iter_lines():
if not raw:
continue
@@ -154,10 +222,15 @@ def create_chat_stream(msg, conv_id):
continue
data = line[5:].strip()
if data == '[DONE]':
completed = True
break
try:
j = json.loads(data)
chunk = j['choices'][0]['delta'].get('content') or ''
choice = j['choices'][0]
chunk = choice.get('delta', {}).get('content') or ''
finish_reason = choice.get('finish_reason') or finish_reason
if finish_reason == 'stop':
completed = True
except Exception:
continue
if chunk:
@@ -167,6 +240,10 @@ def create_chat_stream(msg, conv_id):
if not got_text:
yield sse({'e': '模型没有返回内容,请重试'})
return
if not completed or finish_reason not in (None, 'stop'):
interrupted = True
yield sse({'e': '回答未生成完整,请点击重试'})
return
full_text = ''.join(parts)
limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN')
if len(full_text) > limit:
@@ -177,6 +254,7 @@ def create_chat_stream(msg, conv_id):
full_text = short
yield sse({'replace': short})
save_message(conv_id, 'assistant', full_text)
answer_saved = True
if is_first_turn:
try:
fu = gen_followups(msg, full_text)
@@ -193,7 +271,9 @@ def create_chat_stream(msg, conv_id):
yield sse({'e': '传输中断,请点击重试'})
finally:
text = ''.join(parts)
if interrupted and len(text) >= 20:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
resp.close()
try:
if interrupted and text and not answer_saved:
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
finally:
resp.close()
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
+1
View File
@@ -20,6 +20,7 @@ def default_config():
'BACKUP_DIR': ROOT_DIR / 'backups',
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
'MODEL_NAME': 'deepseek-chat',
'MAX_CONTEXT': 4,
+22 -1
View File
@@ -87,6 +87,20 @@ def init_db():
hm_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_messages)').fetchall()]
if 'read_at' not in hm_cols:
cur.execute('ALTER TABLE human_messages ADD COLUMN read_at TEXT')
if 'recalled_id' not in hm_cols:
cur.execute('ALTER TABLE human_messages ADD COLUMN recalled_id INTEGER')
hs_cols = [r[1] for r in cur.execute('PRAGMA table_info(human_sessions)').fetchall()]
if 'note' not in hs_cols:
cur.execute("ALTER TABLE human_sessions ADD COLUMN note TEXT DEFAULT ''")
cur.execute('''
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sid INTEGER NOT NULL,
stars INTEGER NOT NULL,
comment TEXT DEFAULT '',
ctime TEXT NOT NULL
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS agents (
username TEXT PRIMARY KEY,
@@ -112,6 +126,8 @@ def init_db():
status TEXT
)
''')
from .order_data import init_order_table
init_order_table(conn)
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
for cid, title in old:
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
@@ -187,7 +203,12 @@ def save_message(conv_id, role, content):
if last and last[0] == 'user' and (last[1] == content):
conn.close()
return 'duplicate'
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (conv_id, role, content, now_str()))
inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) '
'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)',
(conv_id, role, content, now_str(), conv_id)).rowcount
if not inserted:
conn.close()
return 'deleted'
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] == '新对话'):
+10 -1
View File
@@ -3,15 +3,20 @@
from .db import connect, now_str
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。'
def new_chat():
now = now_str()
conn = connect()
cur = conn.cursor()
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
conv_id = cur.lastrowid
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
(conv_id, 'assistant', WELCOME_TEXT, now))
conn.commit()
conn.close()
return {'id': conv_id}
return {'id': conv_id, 'welcome': WELCOME_TEXT}
def get_history():
@@ -32,6 +37,8 @@ def get_messages(conv_id):
def del_history(hid):
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions WHERE conv_id=?)', (hid,))
cur.execute('DELETE FROM human_sessions WHERE conv_id=?', (hid,))
cur.execute('DELETE FROM messages WHERE conv_id=?', (hid,))
cur.execute('DELETE FROM chat_history WHERE id=?', (hid,))
conn.commit()
@@ -42,6 +49,8 @@ def del_history(hid):
def clear_all():
conn = connect()
cur = conn.cursor()
cur.execute('DELETE FROM human_messages')
cur.execute('DELETE FROM human_sessions')
cur.execute('DELETE FROM messages')
cur.execute('DELETE FROM chat_history')
conn.commit()
+74 -7
View File
@@ -10,8 +10,14 @@ def human_request(data):
conv_id = data.get('conv_id')
if not conv_id:
return ({'ok': False, 'error': 'no conv'}, 400)
if not 8 <= datetime.now().hour < 22:
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
conn.close()
return ({'ok':False, 'error':'conversation_not_found'}, 404)
row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
if row:
sid = row[0]
@@ -25,24 +31,35 @@ def human_request(data):
def human_poll(sid, after, typing_map):
conn = connect()
msgs = conn.execute('SELECT id, side, content, ctime, read_at FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
st = conn.execute("SELECT status, COALESCE(note, '') FROM human_sessions WHERE id=?", (sid,)).fetchone()
conn.close()
is_typing = False
ts = typing_map.get(str(sid))
if ts and time.time() - ts < 5:
is_typing = True
return {'status': st[0] if st else 'closed', 'typing': is_typing, 'messages': [{'id': m[0], 'side': m[1], 'content': m[2], 'ctime': m[3], 'read': bool(m[4])} for m in msgs]}
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
def human_send(data):
sid = data.get('sid')
side = data.get('side', 'customer')
content = (data.get('content') or '').strip()
if not sid or not content:
content = data.get('content') or ''
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
return ({'ok': False}, 400)
content = content.strip()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
if not row:
conn.close()
return ({'ok': False, 'error': 'session_not_found'}, 404)
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
conn.close()
return ({'ok': False, 'error': 'conversation_changed'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
conn.commit()
conn.close()
@@ -66,10 +83,11 @@ def human_active():
def human_accept(data):
sid = data.get('sid')
conn = connect()
conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,))
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
conn.commit()
conn.close()
return {'ok': True}
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
def human_close(data):
@@ -81,6 +99,55 @@ def human_close(data):
return {'ok': True}
def human_transfer(data, typing_map):
sid = data.get('sid')
conn = connect()
changed = conn.execute("UPDATE human_sessions SET status='waiting' WHERE id=? AND status='active'", (sid,)).rowcount
if not changed:
conn.close()
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
conn.commit()
conn.close()
typing_map.pop(str(sid), None)
return {'ok': True}
def human_note(data):
note = data.get('note', '')
if not isinstance(note, str):
return ({'ok': False}, 400)
conn = connect()
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
conn.commit()
conn.close()
return {'ok': True} if changed else ({'ok': False}, 404)
def human_recall(data):
conn = connect()
mid = data.get('mid')
# Only the first request can change the original message; retries emit no duplicate event.
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT session_id, side, content FROM human_messages WHERE id=?', (mid,)).fetchone()
if not row:
conn.close()
return ({'ok': False}, 404)
if row[1] == 'system' and row[2] == '该消息已撤回':
conn.close()
return {'ok': True}
if row[1] != 'agent':
conn.close()
return ({'ok': False}, 400)
conn.execute("UPDATE human_messages SET side='system', content='该消息已撤回' WHERE id=?", (mid,))
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime, recalled_id) VALUES(?,?,?,?,?)',
(row[0], 'system', '该消息已撤回', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), mid))
conn.commit()
conn.close()
return {'ok': True}
def human_typing(data, typing_map):
sid = data.get('sid')
typing_map[str(sid)] = time.time()
+205
View File
@@ -0,0 +1,205 @@
"""Local, editable order test data. This repository can later be replaced by MCP."""
import json
import re
import sqlite3
from datetime import datetime
from .config import get_setting
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', 'data_note', 'source_updated_at', 'snapshot_at')
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
'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'状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
pass
def init_order_table(connection):
connection.execute('''CREATE TABLE IF NOT EXISTS order_test_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_order_id TEXT, tenant_id TEXT NOT NULL DEFAULT 'test',
order_no TEXT NOT NULL COLLATE NOCASE,
inbound_code TEXT COLLATE NOCASE, shipment_id TEXT COLLATE NOCASE,
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 '[]', data_note TEXT,
source_updated_at TEXT, snapshot_at TEXT,
UNIQUE(tenant_id, order_no)
)''')
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)')
def import_records(connection, records, replace=False):
"""Import an explicitly supplied snapshot; preserve manual edits unless requested."""
init_order_table(connection)
inserted = skipped = 0
for source in records:
if not isinstance(source, dict) or not str(source.get('order_no') or '').strip():
raise ValueError('Each record requires an order_no')
row = {key: source.get(key) for key in FIELDS}
row['tenant_id'] = str(row['tenant_id'] or 'test')
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')
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')
row['history_json'] = json.dumps(history, ensure_ascii=False)
row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds')
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
(row['tenant_id'], row['order_no'])).fetchone()
if exists and not replace:
skipped += 1
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'))
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
return {'imported': inserted, 'preserved': skipped}
def lookup_order(reference):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect
connection = connect()
connection.row_factory = sqlite3.Row
try:
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)'
values = [reference] * 3
tenant = get_setting('ORDER_DATA_TENANT_ID')
if tenant:
query += ' AND tenant_id=?'
values.append(str(tenant))
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
except sqlite3.Error:
raise OrderDataUnavailable from None
finally:
connection.close()
def extract_reference(message):
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)
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)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
references = []
for token in IDENTIFIER.findall(message):
if sum(character.isdigit() for character in token) < 3:
continue
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
continue
if token.casefold() not in [value.casefold() for value in references]:
references.append(token)
return references
def build_card(row):
fields = []
def field(key, value):
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
field('inbound', row['inbound_code'])
field('shipment', row['shipment_id'])
field('country', row['country_code'])
field('warehouse', row['warehouse_code'])
field('transport', {'SEA':'海运', 'AIR':'空运', 'RAIL':'铁路', 'TRUCK':'卡航'}.get(row['transport_mode'], row['transport_mode']))
field('packages', row['package_count'])
field('weight', str(row['gross_weight_kg'])+' kg' if row['gross_weight_kg'] is not None else None)
field('volume', str(row['volume_cbm'])+' m³' if row['volume_cbm'] is not None else None)
field('role', {'CHILD':'子订单', 'MASTER':'主订单'}.get(row['order_role']))
field('parent', row['parent_order_no'])
field('updated', row['source_updated_at'])
try:
history = json.loads(row['history_json'])
except (ValueError, TypeError):
history = []
nodes = [{'time': str(event.get('time') or ''),
'text': STATUS_LABELS.get(str(event.get('status')), str(event.get('status') or '状态更新')), 'place': ''}
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
nodes.reverse()
status = STATUS_LABELS.get(row['status'], row['status'])
summary = f"订单号 {row['order_no']},业务状态:{status}。"
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
return {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
'nodes':nodes, 'summary':summary}
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
if not re.search(ORDER_DETAIL, message):
return False
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
return True
question = re.sub(r'[\s,,。.!!??、::]', '', message)
return bool(re.fullmatch(
r'(?:那|那么|还有|请问|请|查一下|看一下)?(?:它的|订单的)?'
r'(?:(?:状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
r'(?:呢|吗|呀|啊)?', question))
def answer_query(message, conv_id):
references = extract_reference(message)
if len(references) > 1:
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
reference = references[0] if references else None
if not reference and conv_id and is_order_followup(message):
from .db import connect
connection = connect()
try:
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
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 re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
return None
try:
matches = lookup_order(reference)
except OrderDataUnavailable:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if not matches:
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])}
+47 -25
View File
@@ -95,35 +95,50 @@ RATES = {
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
for m in re.finditer(r"(\d+)[*xX×](\d+)[*xX×](\d+)", t):
# 逐段向后延长,保证拿到的是完整数字而不是半截
dims = []
ok = True
pos = m.start()
for g in (1, 2, 3):
end = m.end(g)
# 若后面还紧跟数字,说明这一组没取全,放弃这个候选
if end < len(t) and t[end].isdigit():
ok = False
break
# 若是第一组,前面不能紧挨数字
if g == 1 and m.start(1) > 0 and t[m.start(1) - 1].isdigit():
ok = False
break
dims.append(int(m.group(g)))
if ok:
return [float(x) for x in dims]
return None
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
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
))
def shipping_region(text):
return next((region for pattern, region in SHIPPING_REGION_PATTERNS if pattern.search(text)), None)
def is_shipping_destination_reply(text):
text = text.strip().strip(',,。.!!??、:: ')
for pattern, _ in SHIPPING_REGION_PATTERNS:
match = pattern.search(text)
if not match:
continue
prefix = re.fullmatch(r'\s*(?:(?:目的地|目的国家|国家)(?:是|为|:|:)?|(?:发|寄|运|送)(?:往|到)?|到|往|去|to)?\s*', text[:match.start()], re.I)
suffix = re.fullmatch(r'\s*(?:的)?\s*(?:FBA\s*)?(?:(?:海外)?仓(?:库)?|warehouse)?(?:呢)?\s*', text[match.end():], re.I)
if prefix and suffix:
return True
return False
def parse_shipping_intent(text):
"""从话里抠出:重量(kg)、渠道、长宽高(cm)"""
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
t = text.replace(" ", "")
intent = {"weight": None, "channel": None, "dims": None}
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
@@ -192,17 +207,21 @@ def pick_rate(weight_kg, tiers):
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
country 传入国家代码(DE/FR/PL/CZ/ES)时,优先选择包含该后缀的仓点组。
country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。
"""
if not RX or not RX.WAREHOUSE_CARD:
return None
keys = list(RX.WAREHOUSE_CARD)
key = warehouse_key
if key and key not in RX.WAREHOUSE_CARD:
return None
if not key and country:
for k in keys:
if ("-" + country) in k.upper():
key = k
break
if not key:
return None
if not key:
key = keys[0]
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
@@ -213,7 +232,9 @@ def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
stores = [s for s in key.splitlines() if s.strip()]
if country:
stores = [s for s in stores if ("-" + country) in s.upper()] or stores
stores = [s for s in stores if ("-" + country) in s.upper()]
if not stores:
return None
else:
stores = stores[:1]
note = "报价表中的仓点分组:" + "、".join(stores)
@@ -315,7 +336,8 @@ def freight_reply(intent):
dims = intent["dims"]
unit = intent.get("dim_unit") or "cm"
lines = []
channels = [intent["channel"]] if intent["channel"] else ["sea", "air"]
default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air']
channels = [intent["channel"]] if intent["channel"] else default_channels
for ch in channels:
c = calc_freight(w, ch, dims, unit)
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
+14 -1
View File
@@ -18,7 +18,20 @@ def admin_login():
@bp.route('/api/export_chat')
def export_chat():
return Response(business_service.export_chat(), mimetype='text/csv', headers={'Content-Disposition': 'attachment;filename=chat_export.csv'})
format = 'csv' if request.args.get('format') == 'csv' else 'xlsx'
mimetype = 'text/csv' if format == 'csv' else 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
return Response(business_service.export_chat(format), mimetype=mimetype,
headers={'Content-Disposition': 'attachment;filename=chat_export.' + format})
@bp.route('/api/agents/list')
def agents_list():
return business_service.agents_list()
@bp.route('/api/search_chat')
def search_chat():
return business_service.search_chat(request.args.get('q', '').strip())
@bp.route('/admin')
+5
View File
@@ -19,3 +19,8 @@ def add_ticket():
@bp.route('/api/tickets')
def list_tickets():
return business_service.list_tickets()
@bp.route('/api/rate', methods=['POST'])
def rate_chat():
return business_service.rate_chat(request.json or {})
+27 -8
View File
@@ -8,17 +8,36 @@ bp = Blueprint('chat', __name__)
@bp.route('/stream_chat', methods=['GET', 'POST'])
def stream_chat():
def error(message):
return Response(chat_service.sse({'e':message}) + chat_service.sse({'d':1}), mimetype='text/event-stream')
if request.method == 'POST':
data = request.get_json(silent=True) or {}
msg = (data.get('msg') or '').strip()
data = request.get_json(silent=True)
if not isinstance(data, dict):
return error('请求格式无效,请重新发送问题。')
msg = data.get('msg', '')
conv_id = data.get('conv_id')
try:
conv_id = int(conv_id) if conv_id else None
except (TypeError, ValueError):
conv_id = None
else:
msg = (request.args.get('msg') or '').strip()
conv_id = request.args.get('conv_id', type=int)
msg = request.args.get('msg', '')
conv_id = request.args.get('conv_id')
if not isinstance(msg, str):
return error('问题必须是文本,请重新输入。')
msg = msg.strip()
if isinstance(conv_id, (bool, float)):
return error('会话编号无效,请新建对话后重试。')
if conv_id not in (None, '', 0):
if not isinstance(conv_id, (str, int)):
return error('会话编号无效,请新建对话后重试。')
if isinstance(conv_id, str) and (len(conv_id) > 19 or not conv_id.isascii() or not conv_id.isdecimal()):
return error('会话编号无效,请新建对话后重试。')
try:
conv_id = int(conv_id)
except (ValueError, TypeError):
return error('会话编号无效,请新建对话后重试。')
if not 0 < conv_id <= 9223372036854775807:
return error('会话编号无效,请新建对话后重试。')
else:
conv_id = None
frames, headers = chat_service.create_chat_stream(msg, conv_id)
body = frames if isinstance(frames, str) else stream_with_context(frames)
return Response(body, mimetype='text/event-stream', headers=headers)
+15
View File
@@ -41,6 +41,21 @@ def human_close():
return human_service.human_close(request.json)
@bp.route('/api/human/transfer', methods=['POST'])
def human_transfer():
return human_service.human_transfer(request.json or {}, current_app.extensions['human_typing'])
@bp.route('/api/human/note', methods=['POST'])
def human_note():
return human_service.human_note(request.json or {})
@bp.route('/api/human/recall', methods=['POST'])
def human_recall():
return human_service.human_recall(request.json or {})
@bp.route('/api/human/typing', methods=['POST'])
def human_typing():
return human_service.human_typing(request.json, current_app.extensions['human_typing'])
+4 -2
View File
@@ -13,7 +13,9 @@ def card_to_text(content):
return content
try:
card = json.loads(content[len(CARD_PREFIX):])
return f"(已为运单 {card.get('no', '')} 查询过轨迹,当前状态:{card.get('status', '')})"
if card.get('kind') == 'order':
return card.get('summary', '')
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
except Exception:
return "(已为该运单查询过轨迹)"
@@ -57,7 +59,7 @@ def extract_tracking_no(text):
return best
def build_track(no):
"""生成固定的模拟轨迹:同一个单号每次查都一样"""
"""Legacy demo helper for old imports; chat queries use order_test_data."""
rnd = random.Random(no)
steps = [
("已揽收", "义乌仓"),
+1
View File
@@ -9,6 +9,7 @@ iniconfig==2.3.1
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.4
openpyxl>=3.1,<4
packaging==26.3
pluggy==1.7.0
Pygments==2.21.0
+32
View File
@@ -0,0 +1,32 @@
"""Import an explicit, private order snapshot into the chat SQLite database."""
import argparse
import json
from pathlib import Path
import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from logistics_chat.order_data import import_records
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--snapshot', type=Path, required=True)
parser.add_argument('--db', type=Path, required=True)
parser.add_argument('--replace', action='store_true', help='Replace existing rows, including manual edits')
args = parser.parse_args()
if not args.snapshot.is_file() or args.snapshot.stat().st_size > 10*1024*1024:
parser.error('Snapshot must be an existing JSON file smaller than 10 MB')
records = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
if isinstance(records, dict): records = records.get('orders')
if not isinstance(records, list) or len(records) > 3000:
parser.error('Snapshot must contain a list of at most 3000 orders')
if not args.db.parent.is_dir(): parser.error('Database parent directory must exist')
with sqlite3.connect(args.db) as connection:
result = import_records(connection, records, replace=args.replace)
print(json.dumps(result))
if __name__ == '__main__':
main()
+1 -1
View File
@@ -60,7 +60,7 @@ if not errorlevel 1 (
echo OK - port is free.
echo [2/3] Checking dependencies ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv" >nul 2>nul
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv, openpyxl" >nul 2>nul
if errorlevel 1 (
echo Missing packages, installing ...
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
+15
View File
@@ -107,6 +107,21 @@ body:not(.has-active-session) .close { display: none; }
#reply-send:hover:enabled { background: var(--brand-hover); }
.reply-status { font-size: 12px; line-height: 1.6; color: var(--success); padding-top: 7px; }
.reply-status.is-error { color: var(--danger); }
.system-msg { display: block; text-align: center; color: var(--muted); font-size: 12px; }
.recall-button { border: 0; background: transparent; padding: 0 0 0 10px; color: var(--muted); font-size: 11px; }
.night-badge { padding: 4px 7px; border-radius: 5px; background: var(--brand-soft); color: var(--secondary); font-size: 11px; }
.note-input { width: 100%; padding: 9px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); resize: vertical; font-size: 13px; }
.search-dialog { width: min(660px, calc(100vw - 32px)); max-height: calc(100dvh - 60px); padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.search-dialog::backdrop { background: #19263d55; }
.search-dialog header, .search-dialog form { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.search-dialog h2 { font-size: 17px; }
.search-dialog input { flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; }
.search-dialog .quick { width: auto; margin: 0; }
.search-result { padding: 14px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
.search-result h3 { font-size: 14px; }
.search-result p { margin: 8px 0; white-space: pre-wrap; font-size: 13px; }
.search-result small { color: var(--muted); }
@media (max-width: 720px) { .night-badge { display: none; } .participant-copy { min-width: 55px; } .chat-header-actions { gap: 4px; } }
.side { display: none; min-height: 0; min-width: 0; overflow-y: auto; background: var(--surface); border-left: 1px solid var(--border); }
.tools-open .layout { grid-template-columns: 300px minmax(0, 1fr) 310px; }
.tools-open .side { display: block; }
+13
View File
@@ -87,12 +87,19 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.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); }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
#upload-btn { flex: 0 0 36px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--secondary); font-size: 18px; }
#upload-btn:hover { color: var(--brand-text); }
.service-rating { margin: 12px auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); text-align: center; color: var(--secondary); font-size: 13px; }
.rating-choices { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
.rating-choices button { padding: 3px 8px; background: transparent; border: 0; color: var(--muted); font-size: 28px; }
.rating-choices button.selected, .rating-choices button:focus-visible { color: #bd8610; }
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
#input-text { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
#input-text::placeholder { color: var(--muted); }
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
@@ -144,6 +151,12 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
.track-expand:hover { background: var(--brand-soft); }
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
.order-data-notice { margin: 12px 0; padding: 10px; border-radius: 7px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; line-height: 1.8; }
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
.order-data-fields dt { color: var(--muted); }
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
.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); }
+6
View File
@@ -76,6 +76,12 @@ svg { display: block; flex-shrink: 0; }
border: 1px solid var(--border); border-radius: 9px; color: var(--secondary); background: var(--surface);
}
.icon-button:hover { color: var(--brand-text); background: var(--brand-soft); }
.text-button { border: 0; padding: 5px 0; background: transparent; color: var(--brand-text); font-size: 12px; }
.emoji-control { position: relative; flex-shrink: 0; }
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
.emoji-picker button:hover { background: var(--surface-hover); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: #b7c3d5; border-radius: 10px; }
+33 -6
View File
@@ -48,6 +48,7 @@ function updateComposer(){
document.querySelectorAll(".quick").forEach(function(button){
button.disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
});
updateAdminTools();
}
function resizeReply(){
@@ -95,11 +96,12 @@ function renderQueue(){
async function loadQueue(){
try{
queueItems = await adminRequest("/api/human/queue");
renderQueue();
if(!curSid && queueItems.length && !sessions[String(queueItems[0].id)]){
accept(queueItems[0].id);
const list = await adminRequest("/api/human/queue");
if(list.some(function(item){ return !queueItems.some(function(old){ return old.id === item.id; }); })){
SupportUI.notify("新客户排队", "有 " + list.length + " 位客户等待接入");
}
queueItems = list;
renderQueue();
}catch(error){
document.getElementById("queue").innerHTML = '<div class="list-empty">队列暂时无法加载,稍后重试</div>';
}
@@ -153,6 +155,7 @@ function switchSession(sid){
resizeReply();
document.getElementById("chatBox").scrollTop = document.getElementById("chatBox").scrollHeight;
setReplyStatus("");
loadSessionNote(sid);
updateComposer();
renderList();
setSessionTab("active");
@@ -168,6 +171,7 @@ function showEmptySession(){
document.getElementById("chatTitle").textContent = "会话接待";
document.getElementById("chat-subtitle").textContent = "选择一段会话,开始回复客户";
replyInput.value = "";
document.getElementById("noteInput").value = "";
setReplyStatus("");
updateComposer();
setChatView(false);
@@ -177,12 +181,28 @@ function appendMsg(message){
const box = document.getElementById("chatBox");
const follow = box.scrollHeight - box.scrollTop - box.clientHeight < 100;
const div = document.createElement("div");
div.setAttribute("data-message-id", String(message.id));
if(message.side === "system"){
div.className = "msg system-msg";
div.textContent = message.content;
box.appendChild(div);
if(follow) box.scrollTop = box.scrollHeight;
return;
}
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
let content = escapeText(message.content);
content = content.replace(/\[图片\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener"><img src="$1" alt="客户附件" class="message-image"></a>');
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
const avatar = message.side === "customer" ? "客" : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 14v-3a8 8 0 0 1 16 0v3M19 17a4 4 0 0 1-4 4h-3"/><rect x="2" y="11" width="4" height="7" rx="2"/><rect x="18" y="11" width="4" height="7" rx="2"/></svg>';
div.innerHTML = '<span class="message-avatar" aria-hidden="true">'+avatar+'</span><div class="message-stack"><div class="meta">'+(message.side === "customer" ? "客户" : "客服")+(message.ctime ? ' · '+escapeText(message.ctime.slice(11,16)) : '')+'</div><div class="bubble">'+content+'</div></div>';
if(message.side === "agent"){
const recall = document.createElement("button");
recall.type = "button";
recall.className = "recall-button";
recall.textContent = "撤回";
recall.onclick = function(){ recallMsg(message.id); };
div.querySelector(".meta").appendChild(recall);
}
box.appendChild(div);
if(follow || message.side === "agent") box.scrollTop = box.scrollHeight;
}
@@ -234,7 +254,7 @@ async function poll(){
const session = sessions[sid];
const state = await adminRequest("/api/human/poll?sid="+Number(sid)+"&after="+session.lastId);
if(sessions[sid] !== session) return;
if(state.status === "closed"){
if(state.status !== "active"){
delete sessions[sid];
delete drafts[sid];
if(sid === curSid) showEmptySession();
@@ -243,8 +263,10 @@ async function poll(){
state.messages.forEach(function(message){
if(session.msgs.some(function(existing){ return existing.id === message.id; })) return;
session.lastId = Math.max(session.lastId, message.id);
if(message.recalled_id && applyAdminRecall(sid, message.recalled_id)) return;
session.msgs.push(message);
if(message.side === "customer" && sid !== curSid) session.unread += 1;
if(message.side === "customer") SupportUI.notify("客户消息 #"+sid, message.content.substring(0, 50));
if(sid === curSid) appendMsg(message);
});
}));
@@ -370,7 +392,7 @@ document.addEventListener("keydown", function(event){
if(event.key === "Escape" && toolsOpen) document.getElementById("tools-close").click();
if(event.key === "Escape") document.getElementById("data-overview").open = false;
if(event.key === "Tab" && toolsOpen && window.matchMedia("(max-width: 1200px)").matches){
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href]'));
const items = Array.from(document.getElementById("service-tools").querySelectorAll('button:not([disabled]), a[href], textarea:not([disabled])'));
const first = items[0], last = items[items.length-1];
if(event.shiftKey && document.activeElement === first){ event.preventDefault(); last.focus(); }
else if(!event.shiftKey && document.activeElement === last){ event.preventDefault(); first.focus(); }
@@ -381,7 +403,12 @@ adminRequest("/api/admin/stats").then(function(data){
[data.total_conv, data.total_msg, data.today_conv, data.good, data.bad].forEach(function(value, index){
document.getElementById("s"+(index+1)).textContent = value;
});
document.getElementById("s6").textContent = (data.rating_avg || 0) + " / 5("+(data.rating_count || 0)+")";
}).catch(function(){});
SupportUI.initEmoji(document.getElementById("agent-emoji-toggle"), document.getElementById("agent-emoji-panel"), replyInput, function(){
if(curSid) drafts[curSid] = replyInput.value;
resizeReply();
});
setToolsOpen(false);
setSessionTab("active");
updateComposer();
+119
View File
@@ -0,0 +1,119 @@
// Workspace notes, transfers, recalls and historical message search.
function updateAdminTools(){
document.getElementById('transfer-session').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
document.getElementById('agent-emoji-toggle').disabled = !curSid || Boolean(sendingSid) || Boolean(closingSid);
document.getElementById('noteInput').disabled = !curSid || Boolean(sessions[curSid].noteLoading);
document.getElementById('note-save').disabled = !curSid || !sessions[curSid].noteLoaded || Boolean(sessions[curSid].noteLoading);
}
async function loadSessionNote(sid){
const session = sessions[sid];
const input = document.getElementById('noteInput');
input.value = session.noteDraft || '';
if(session.noteLoaded){ updateAdminTools(); return; }
session.noteLoading = true;
updateAdminTools();
try{
const state = await adminRequest('/api/human/poll?sid='+Number(sid)+'&after=0');
if(sessions[sid] !== session) return;
session.noteDraft = state.note || '';
session.noteLoaded = true;
if(curSid === sid) input.value = session.noteDraft;
}catch(error){
if(curSid === sid) setReplyStatus('备注加载失败,请重新选择会话后重试。', true);
}finally{
session.noteLoading = false;
updateAdminTools();
}
}
async function saveNote(){
if(!curSid || !sessions[curSid].noteLoaded) return;
const sid = curSid, session = sessions[sid];
const note = document.getElementById('noteInput').value;
session.noteDraft = note;
try{
await postAdmin('/api/human/note', {sid:Number(sid), note:note});
if(curSid === sid) setReplyStatus(session.noteDraft === note ? '备注已保存' : '先前备注已保存,当前修改尚未保存');
}catch(error){
if(curSid === sid) setReplyStatus('备注保存失败,内容已保留。', true);
}
}
async function transferSession(){
if(!curSid || closingSid || sendingSid) return;
if(!window.confirm('确定转接这个对话?转接后其他客服可在排队列表接入。')) return;
const sid = curSid;
closingSid = sid;
updateComposer();
try{
await postAdmin('/api/human/transfer', {sid:Number(sid)});
delete sessions[sid];
if(curSid === sid) showEmptySession();
renderList();
loadQueue();
}catch(error){
if(curSid === sid) setReplyStatus('转接未成功,会话仍保留。', true);
}finally{
closingSid = '';
updateComposer();
}
}
function applyAdminRecall(sid, mid){
const message = sessions[sid].msgs.find(function(item){ return item.id === mid; });
if(message){ message.side = 'system'; message.content = '该消息已撤回'; }
if(curSid === sid){
const row = document.querySelector('#chatBox [data-message-id="'+Number(mid)+'"]');
if(row){ row.className = 'msg system-msg'; row.textContent = '该消息已撤回'; }
}
return Boolean(message);
}
async function recallMsg(mid){
if(!curSid) return;
const sid = curSid;
try{
await postAdmin('/api/human/recall', {mid:mid});
if(sessions[sid]){ applyAdminRecall(sid, mid); renderList(); }
}catch(error){
if(curSid === sid) setReplyStatus('撤回失败,请稍后重试。', true);
}
}
let searchVersion = 0;
async function doSearch(){
const keyword = document.getElementById('searchBox').value.trim();
const version = ++searchVersion;
const body = document.getElementById('searchBody');
if(!keyword){ body.textContent = '请输入关键词搜索历史消息'; return; }
body.textContent = '正在搜索…';
try{
const list = await adminRequest('/api/search_chat?q='+encodeURIComponent(keyword));
if(version !== searchVersion) return;
const roles = {user:'客户', assistant:'客服', system:'系统'};
body.innerHTML = list.length ? list.map(function(item){
return '<article class="search-result"><h3>'+escapeText(item.title)+'</h3><p>'+escapeText(item.content)+'</p><small>'+escapeText(roles[item.role] || item.role)+' · '+escapeText(item.ctime)+'</small></article>';
}).join('') : '没有匹配的历史消息';
}catch(error){
if(version === searchVersion) body.textContent = '搜索失败,请稍后重试';
}
}
document.getElementById('history-search-toggle').onclick = function(){
document.getElementById('searchResult').showModal();
document.getElementById('searchBox').focus();
};
document.getElementById('search-close').onclick = function(){ document.getElementById('searchResult').close(); };
document.getElementById('history-search-form').addEventListener('submit', function(event){ event.preventDefault(); doSearch(); });
document.getElementById('note-save').onclick = saveNote;
document.getElementById('noteInput').addEventListener('input', function(){
if(curSid) sessions[curSid].noteDraft = this.value;
});
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
function updateNightBadge(){
const hour = new Date().getHours();
document.getElementById('nightBadge').hidden = hour >= 8 && hour < 22;
}
updateNightBadge();
setInterval(updateNightBadge, 60000);
+505 -166
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
// Shared controls for the customer page and service workspace.
var SupportUI = {
async enableNotifications(button){
if(!('Notification' in window)) return;
try{
const permission = await Notification.requestPermission();
button.setAttribute('aria-pressed', String(permission === 'granted'));
}catch(error){}
},
notify(title, body){
if(!document.hidden || !('Notification' in window) || Notification.permission !== 'granted') return;
try{
const notice = new Notification(title, {body:body});
notice.onclick = function(){ window.focus(); notice.close(); };
}catch(error){}
},
addRating(container, sid, labels){
const box = document.createElement('section');
box.className = 'service-rating';
const question = document.createElement('p');
question.textContent = labels.question;
box.appendChild(question);
const choices = document.createElement('div');
choices.className = 'rating-choices';
box.appendChild(choices);
const status = document.createElement('p');
status.setAttribute('role', 'status');
box.appendChild(status);
let sending = false;
[1, 2, 3, 4, 5].forEach(function(stars){
const button = document.createElement('button');
button.type = 'button';
button.textContent = '★';
button.setAttribute('aria-label', stars+' / 5');
button.onmouseenter = function(){
Array.from(choices.children).forEach(function(item, index){ item.classList.toggle('selected', index < stars); });
};
button.onclick = async function(){
if(sending) return;
sending = true;
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
try{
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Rating failed');
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
}catch(error){
sending = false;
status.textContent = labels.failed;
Array.from(choices.children).forEach(function(item){ item.disabled = false; });
}
};
choices.appendChild(button);
});
container.appendChild(box);
return box;
},
initEmoji(button, panel, input, onInsert){
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
button.onclick = function(){
panel.hidden = !panel.hidden;
button.setAttribute('aria-expanded', String(!panel.hidden));
};
emojis.forEach(function(emoji){
const choice = document.createElement('button');
choice.type = 'button';
choice.textContent = emoji;
choice.onclick = function(){
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
const end = input.selectionEnd == null ? start : input.selectionEnd;
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
input.focus();
input.setSelectionRange(start + emoji.length, start + emoji.length);
if(onInsert) onInsert();
};
panel.appendChild(choice);
});
panel.addEventListener('keydown', function(event){
if(event.key === 'Escape'){
panel.hidden = true;
button.setAttribute('aria-expanded', 'false');
button.focus();
}
});
}
};
+11 -2
View File
@@ -15,6 +15,8 @@
<span class="nav-divider" aria-hidden="true"></span><h1>客服工作台</h1>
</div>
<div class="topbar-actions">
<span id="nightBadge" class="night-badge" hidden>非工作时段</span>
<button id="notifications-toggle" class="icon-button" type="button" aria-label="开启浏览器通知" title="开启浏览器通知" aria-pressed="false">🔔</button>
<a class="workspace-link" href="/">客户入口 ↗</a>
<details class="data-overview" id="data-overview">
<summary><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M4 4v16h17M8 16v-5M13 16V7M18 16V3"/></svg>数据概览</summary>
@@ -25,7 +27,8 @@
<div class="stat"><span>今日新对话</span><b id="s3">—</b></div>
<div class="stat"><span>有帮助</span><b id="s4">—</b></div>
<div class="stat"><span>需改进</span><b id="s5">—</b></div>
<a class="export-link" href="/api/export_chat">导出记录 ↗</a>
<div class="stat"><span>服务评价</span><b id="s6">—</b></div>
<a class="export-link" href="/api/export_chat">导出 Excel ↗</a>
</div>
</details>
</div>
@@ -36,6 +39,7 @@
<div class="list-heading">
<h2>客户会话</h2><p>查看咨询,及时接待与回复</p>
<div class="session-search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg><input id="session-search" type="search" aria-label="搜索客户会话" placeholder="搜索会话或消息" autocomplete="off"></div>
<button id="history-search-toggle" class="text-button" type="button">搜索历史记录 ↗</button>
</div>
<div class="session-tabs" role="tablist" aria-label="会话状态">
<button id="tab-active" type="button" role="tab" aria-selected="true" aria-controls="sessionList">接待中 <span id="active-count" class="section-count">0</span></button>
@@ -54,6 +58,7 @@
<div class="participant-copy"><h2 id="chatTitle">会话接待</h2><p id="chat-subtitle">选择一段会话,开始回复客户</p></div>
<div class="chat-header-actions">
<button id="tools-toggle" class="tools-toggle" type="button" aria-label="资料与快捷回复" aria-controls="service-tools" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 8h3M14 12h3M14 16h3"/></svg><span>资料与回复</span></button>
<button id="transfer-session" class="close" type="button" onclick="transferSession()">转接</button>
<button id="close-session" class="close" type="button" onclick="closeSession()">结束会话</button>
</div>
</header>
@@ -64,7 +69,7 @@
<div id="chatContent" class="session-panel" style="display:none">
<div id="chatBox" role="log" aria-live="polite" aria-label="会话消息"></div>
<footer class="agent-composer">
<div class="composer-heading"><label for="replyText">回复客户</label><span>Shift + Enter 换行</span></div>
<div class="composer-heading"><label for="replyText">回复客户</label><div class="emoji-control"><button id="agent-emoji-toggle" class="text-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="agent-emoji-panel">😊 表情</button><div id="agent-emoji-panel" class="emoji-picker" hidden></div></div><span>Shift + Enter 换行</span></div>
<div class="input-bar">
<textarea id="replyText" rows="2" aria-label="回复客户" placeholder="输入回复内容,按 Enter 发送…"></textarea>
<div class="input-actions"><span>常用回复可在「资料与回复」中选择</span><button id="reply-send" type="button" onclick="sendReply()">发送</button></div>
@@ -76,6 +81,7 @@
<aside class="side" id="service-tools" aria-label="客户信息与回复" aria-hidden="true" inert>
<header class="side-heading"><h2>服务资料</h2><button id="tools-close" type="button" class="icon-button" aria-label="关闭服务资料">×</button></header>
<section class="card"><h3>客户信息</h3><div class="info-row" id="cInfo">选择会话后查看客户信息</div><p class="info-hint">咨询运费时,可先确认运单号、货物重量、尺寸与目的地。</p></section>
<section class="card"><h3><label for="noteInput">会话备注</label></h3><textarea id="noteInput" class="note-input" rows="3" placeholder="填写本次会话的跟进事项" disabled></textarea><button id="note-save" type="button" class="quick" disabled>保存备注</button></section>
<section class="card"><h3>常用回复</h3>
<p class="card-caption">点击填入草稿,编辑后再发送</p>
<button type="button" class="quick" onclick="quickReply('您好,请问有什么可以帮您?')">您好,有什么可以帮您?</button>
@@ -87,6 +93,9 @@
</aside>
</main>
<button class="tools-backdrop" id="tools-backdrop" type="button" aria-label="关闭服务资料"></button>
<dialog id="searchResult" class="search-dialog" aria-labelledby="search-title"><header><h2 id="search-title">搜索历史记录</h2><button id="search-close" class="icon-button" type="button" aria-label="关闭搜索">×</button></header><form id="history-search-form"><label class="sr-only" for="searchBox">历史消息关键词</label><input id="searchBox" type="search" placeholder="输入关键词搜索聊天记录" required><button type="submit" class="quick">搜索</button></form><div id="searchBody" aria-live="polite">请输入关键词搜索历史消息</div></dialog>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin_tools.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</body>
</html>
+5 -1
View File
@@ -31,6 +31,7 @@
<button class="toggle-divider" type="button" aria-label="切换历史侧栏" aria-expanded="true"></button>
<div class="header-copy"><div class="header-kicker">BAIYUE · CUSTOMER SUPPORT</div><span id="app-title">柏悦物流智能助手</span></div>
<div class="title-actions">
<button type="button" id="notifications-toggle" class="theme-toggle" title="开启浏览器通知" aria-label="开启浏览器通知" aria-pressed="false">🔔</button>
<span class="service-label"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceTag">智能客服</span></span>
<button type="button" id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<button type="button" id="font-toggle" class="theme-toggle" title="字体大小">16px</button>
@@ -43,11 +44,12 @@
<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>
<h3>物流问题,随时为您解答</h3>
<p>查询物流轨迹、了解专线报价,或联系人工客服。<br>从这里开始您的物流咨询。</p>
<p>查询订单状态、了解专线报价,或联系人工客服。<br>从这里开始您的物流咨询。</p>
</div>
</section>
</div>
<footer class="composer">
<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="hot-bar">
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
@@ -57,6 +59,7 @@
</div>
<div class="input-area">
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
<button type="button" id="send-button">发送</button>
@@ -67,6 +70,7 @@
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
</main>
</div>
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
</body>
</html>
+86 -1
View File
@@ -4,6 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../../static/js/admin.js'), 'utf8');
const sharedSource = fs.readFileSync(path.join(__dirname, '../../static/js/support.js'), 'utf8');
const toolsSource = fs.readFileSync(path.join(__dirname, '../../static/js/admin_tools.js'), 'utf8');
const flush = () => new Promise(setImmediate);
function response(data, status = 200){
@@ -32,6 +34,13 @@ async function workspace(compact = false){
appendChild(element){ this.children.push(element); }
addEventListener(name, callback){ this.listeners[name] = callback; }
querySelectorAll(){ return []; }
querySelector(selector){
if(selector === '.meta') return this.meta || (this.meta = new Element());
return null;
}
setSelectionRange(start, end){ this.selectionStart = start; this.selectionEnd = end; }
showModal(){ this.open = true; }
close(){ this.open = false; }
focus(){ document.activeElement = this; }
click(){ if(this.onclick) return this.onclick(); }
}
@@ -41,24 +50,36 @@ async function workspace(compact = false){
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
createElement(){ return new Element(); },
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
querySelector(selector){
const match = selector.match(/data-message-id="(\d+)"/);
if(!match) return null;
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
},
addEventListener(name, callback){ documentListeners[name] = callback; },
};
const calls = [];
const hooks = {};
const context = vm.createContext({
document, console,
window: {matchMedia: () => ({matches: compact}), addEventListener(){}},
window: {matchMedia: () => ({matches: compact}), addEventListener(){}, confirm(){ return true; }},
setInterval(){}, setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
const body = options && options.body ? JSON.parse(options.body) : undefined;
calls.push({url, body});
if(url === '/api/human/send' && hooks.send) return hooks.send(body);
if(url === '/api/human/close' && hooks.close) return hooks.close(body);
if(url === '/api/human/transfer' && hooks.transfer) return hooks.transfer(body);
if(url === '/api/human/note' && hooks.note) return hooks.note(body);
if(url === '/api/human/queue' && hooks.queue) return hooks.queue();
if(url.includes('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.includes('/api/search_chat') && hooks.search) return hooks.search(url);
if(url.includes('/api/human/poll')) return response({status:'active', messages:[]});
if(url === '/api/admin/stats') return response({total_conv:0, total_msg:0, today_conv:0, good:0, bad:0});
return response(options ? {ok:true} : []);
},
});
vm.runInContext(sharedSource, context, {filename:'support.js'});
vm.runInContext(toolsSource, context, {filename:'admin_tools.js'});
vm.runInContext(source, context, {filename:'admin.js'});
await flush();
await context.accept(7);
@@ -211,3 +232,67 @@ test('mobile drafts are measured after the chat view becomes visible', async ()
assert.equal(input.style.height, '128px');
assert.equal(input.value, '需要恢复的多行草稿');
});
test('transfer returns a conversation to the queue without auto-accepting it again', async () => {
const app = await workspace();
app.hooks.queue = () => response([{id:7, ctime:'now'}]);
await app.context.transferSession();
await flush();
assert.equal(app.context.curSid, 0);
assert.equal(app.context.sessions['7'], undefined);
assert.equal(app.calls.filter(call => call.url === '/api/human/accept').length, 1);
});
test('failed transfers retain the conversation and draft', async () => {
const app = await workspace();
app.hooks.transfer = () => response({ok:false}, 503);
app.element('replyText').value = '等待确认的草稿';
await app.context.transferSession();
assert.equal(app.context.curSid, '7');
assert.equal(app.element('replyText').value, '等待确认的草稿');
assert.match(app.element('reply-status').textContent, /转接未成功/);
});
test('a late note response cannot overwrite a different session note', async () => {
const app = await workspace();
app.context.sessions['7'].noteLoaded = false;
let resolve;
app.hooks.poll = url => url.includes('sid=7&') ? new Promise(done => { resolve = done; }) : response({status:'active', note:'客户 8 的备注', messages:[]});
const first = app.context.loadSessionNote('7');
app.context.sessions['8'] = {lastId:0, msgs:[], unread:0};
app.context.switchSession('8');
await flush();
resolve(response({status:'active', note:'客户 7 的备注', messages:[]}));
await first;
assert.equal(app.element('noteInput').value, '客户 8 的备注');
});
test('failed note saves keep the edited note available', async () => {
const app = await workspace();
app.hooks.note = () => response({ok:false}, 503);
app.element('noteInput').value = '下个班次继续跟进';
await app.context.saveNote();
assert.equal(app.element('noteInput').value, '下个班次继续跟进');
assert.match(app.element('reply-status').textContent, /备注保存失败/);
});
test('recalling an older reply leaves the newest reply intact', async () => {
const app = await workspace();
app.context.sessions['7'].msgs = [
{id:1, side:'agent', content:'较早回复'}, {id:2, side:'agent', content:'最新回复'},
];
app.context.sessions['7'].msgs.forEach(app.context.appendMsg);
await app.context.recallMsg(1);
assert.equal(app.context.sessions['7'].msgs[0].content, '该消息已撤回');
assert.equal(app.context.sessions['7'].msgs[1].content, '最新回复');
assert.equal(app.element('chatBox').children[0].textContent, '该消息已撤回');
});
test('historical search renders customer text without interpreting HTML', async () => {
const app = await workspace();
app.hooks.search = () => response([{title:'<img onerror=bad()>', content:'<script>bad()</script>', role:'user', ctime:'now'}]);
app.element('searchBox').value = '物流';
await app.context.doSearch();
assert.match(app.element('searchBody').innerHTML, /&lt;script&gt;/);
assert.doesNotMatch(app.element('searchBody').innerHTML, /<script>/);
});
+541
View File
@@ -0,0 +1,541 @@
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');
const flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){
const elements = new Map(), listeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
this.listeners = {};
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(' '); },
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
};
}
set innerHTML(value){ 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(''); }
setAttribute(name, value){ this.attributes[name] = value; }
removeAttribute(name){ delete this.attributes[name]; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(name, callback){ this.listeners[name] = callback; }
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
focus(){}
querySelector(selector){
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector){
const matches = element => {
if(selector.startsWith('.')) return element.classList.contains(selector.slice(1));
const id = selector.match(/data-human-message-id="(\d+)"/);
return id && element.attributes['data-human-message-id'] === id[1];
};
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), 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; },
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(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
}})}};
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/human/poll') ? state
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
});
for(const file of ['support.js', 'chat.js']){
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
}
listeners.DOMContentLoaded();
await flush();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, hooks, quickButtons,
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(); },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
}
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
const app = await customerPage();
app.setState({status:'closed', messages:[{id:1, side:'agent', content:'最后一条回复'}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /最后一条回复/);
assert.match(log.textContent, /人工客服已结束/);
const rating = log.querySelector('.service-rating');
assert.ok(rating);
await rating.querySelector('.rating-choices').children[4].onclick();
assert.match(rating.textContent, /感谢您的评价/);
assert.equal(app.calls.find(call => call.url === '/api/rate').body.stars, 5);
assert.equal(app.context.window.humanSid, 0);
});
function streamResponse(chunks){
let index=0;
return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length
? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]}
: {done:true}})}};
}
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; });
const sending=app.context.window.fillInput('第一条问题');
app.element('input-text').value='正在补充的下一条问题';
resolve(response({id:18,welcome:'welcome'}));
await sending;
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题');
assert.equal(app.element('input-text').value, '正在补充的下一条问题');
});
test('switching to a quick topic discards the previous topic attachment', async () => {
const app=await customerPage({human:false});
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, '欧洲海运专线报价');
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
const app=await customerPage({human:false});
const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n');
const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5));
app.hooks['/stream_chat']=() => streamResponse(chunks);
await app.context.window.fillInput('请回复');
await flush();
assert.match(app.element('chatDom').textContent, /完整中文回复/);
assert.equal(app.element('chatDom').querySelector('.retry-btn'), null);
assert.equal(app.context.window.__busy, false);
});
test('a stream ending without completion retains partial text and offers retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('请继续说明');
await flush();
assert.match(app.element('chatDom').textContent, /已经收到的部分回答/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
assert.equal(app.context.window.__busy, false);
});
test('rate limit responses show the server message and allow retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429);
await app.context.window.fillInput('请查单');
await flush();
assert.match(app.element('chatDom').textContent, /请求太频繁/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
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) => {
if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'})));
});
app.context.window.fillInput('请帮我查询');
await app.runTimers(10000);
assert.match(app.element('composer-status').textContent, /会话创建失败/);
assert.equal(app.element('input-text').value, '请帮我查询');
app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'});
await app.context.window.fillInput('请帮我查询');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('history refresh failures preserve the last successfully loaded list', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await flush();
assert.match(app.element('historyWrap').textContent, /保留的对话/);
});
test('deleting the active conversation aborts its stream and resets the composer', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}});
await app.context.window.fillInput('请等待回复');
await flush();
const remove=app.element('historyWrap').children[0].children[2];
await remove.onclick({stopPropagation(){}});
await flush();
assert.equal(app.context.window.__busy,false);
assert.equal(app.element('send-button').textContent,'发送');
assert.ok(app.calls.find(call => call.url === '/stream_chat'));
});
test('switching topics aborts an upload and does not erase the new conversation status', async () => {
const app=await customerPage({human:false});
let aborted=false;
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); });
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('an upload timeout releases the composer and reports an actionable failure', async () => {
const app=await customerPage({human:false});
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'slow.png'}];
app.element('upload-input').onchange();
await app.runTimers(15000);
assert.equal(app.element('upload-btn').disabled,false);
assert.match(app.element('composer-status').textContent,/附件上传失败/);
await app.context.window.fillInput('请继续查单');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('malformed stored pin preferences cannot break the conversation list', async () => {
const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]});
assert.match(app.element('historyWrap').textContent,/正常对话/);
});
test('clearing history blocks new conversations until its timed request settles', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]});
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
});
app.element('.clear-all-btn').click();
await app.click(app.quickButtons[1]);
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('a failed history deletion preserves the active conversation for the next message', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/api/del_history?id=9']=() => response({ok:false},503);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
await app.context.window.fillInput('继续这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
});
test('successful deletion updates the local list even when its refresh is rate limited', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待删除会话',ctime:'now'},{id:10,title:'保留会话',ctime:'now'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
assert.doesNotMatch(app.element('historyWrap').textContent,/待删除会话/);
assert.match(app.element('historyWrap').textContent,/保留会话/);
});
test('successful clearing removes cached history when the followup refresh fails', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'待清空会话',ctime:'now'}]});
app.hooks['/api/clear_all']=() => response({ok:true});
app.hooks['/api/get_history']=() => Promise.reject(new Error('offline'));
app.element('.clear-all-btn').click();
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/待清空会话/);
assert.ok(app.element('historyWrap').querySelector('.history-empty'));
});
test('a history refresh started before a deletion cannot resurrect the deleted item', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'旧刷新中的会话',ctime:'now'}]});
let resolve, refreshes=0;
app.hooks['/api/get_history']=() => ++refreshes === 1 ? new Promise(done => { resolve=done; }) : response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
resolve(response([{id:9,title:'旧刷新中的会话',ctime:'now'}]));
await flush();
assert.doesNotMatch(app.element('historyWrap').textContent,/旧刷新中的会话/);
});
test('history text and order cards preserve their original times and card actions', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'历史时间',ctime:'2026-10-28 07:12'}]});
app.hooks.messages=() => response([
{role:'user',content:'FL202610290001',ctime:'2026-10-28 07:12'},
{role:'assistant',content:'__CARD__'+JSON.stringify({kind:'order',no:'FL202610290001',status:'履约中',fields:[],nodes:[],summary:'订单概要'}),ctime:'2026-10-28 07:13'},
{role:'assistant',content:'后续补充回复',ctime:'2026-10-28 07:14'}
]);
await app.click(app.element('historyWrap').children[0]);
const times=app.element('chatDom').querySelectorAll('.msg-time');
assert.deepEqual(times.map(item => item.textContent),['07:12','07:13','07:14']);
assert.equal(times[0].title,'2026-10-28 07:12');
assert.ok(app.element('chatDom').querySelector('.track-card').querySelector('.msg-actions'));
});
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
const app = await customerPage();
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});
await app.tick();
app.setState({status:'active', messages:[{id:3, side:'system', content:'该消息已撤回', recalled_id:1}]});
await app.tick();
const log = app.element('chatDom');
assert.match(log.textContent, /该消息已撤回/);
assert.match(log.textContent, /最新回复/);
assert.doesNotMatch(log.textContent, /早先的回复/);
});
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
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.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
const app = await customerPage({human:false});
const button = app.element('contact-card');
button.setAttribute('data-human-support', '');
app.element('chatDom').listeners.click({target:button});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
test('new chat clears human routing and the next quote is sent to AI', async () => {
const app = await customerPage();
await app.click(app.element('.new-chat-btn'));
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.context.window.__busy, false);
});
test('opening AI history clears the human session and retains that conversation id', async () => {
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
await app.context.window.fillInput('请继续回答这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
assert.equal(app.context.window.humanSid, 0);
const authors = app.element('chatDom').querySelectorAll('.message-author');
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
});
test('a repeated quick click while a new conversation is being created sends only once', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('a late conversation creation cannot replace the newest quick question', async () => {
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
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(app.context.window.__busy, false);
});
test('a late history response cannot overwrite the latest quick conversation', async () => {
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
let resolve;
app.hooks.messages = () => new Promise(done => { resolve=done; });
await app.click(app.element('historyWrap').children[0]);
await app.click(app.quickButtons[1]);
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
});
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
resolve(response({ok:true, session_id:8}));
await connecting;
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('repeated contact clicks create one human request and show a visible queue state', async () => {
const app = await customerPage({human:false});
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.element('human-session').hidden, false);
assert.match(app.element('human-session-label').textContent, /正在排队/);
});
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0);
});
test('a failed human send preserves the text and makes retry possible', async () => {
const app = await customerPage();
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
await app.context.window.fillInput('请确认这批货的价格');
assert.equal(app.element('input-text').value, '请确认这批货的价格');
assert.match(app.element('composer-status').textContent, /内容已保留/);
assert.equal(app.element('send-button').disabled, false);
});
test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage();
let resolve;
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
});
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
const app = await customerPage();
await app.click(app.element('return-ai'));
await app.context.window.fillInput('请继续解答');
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
const app = await customerPage();
let resolve;
app.hooks.poll = () => new Promise(done => { resolve=done; });
await app.tick();
await app.click(app.quickButtons[1]);
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
});
test('human poll failures are visible and clear after the connection recovers', async () => {
const app = await customerPage();
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
await app.tick();
assert.match(app.element('composer-status').textContent, /连接中断/);
delete app.hooks.poll;
await app.tick();
assert.equal(app.element('composer-status').hidden, true);
});
test('order cards show facts as text without import metadata, including older saved cards', async () => {
const app = await customerPage({human:false});
const card = {kind:'order', no:'FL202610290001', status:'履约中',
fields:[{key:'weight',value:'30.000 kg'},{key:'warehouse',value:'<img src=x onerror=bad()>'}],
nodes:[{time:'2026-10-29 10:00',text:'已提交',place:''}],
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:'旧数据说明'};
app.hooks['/stream_chat'] = () => {
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {done:false,value:new TextEncoder().encode('data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n')};
}})}};
};
await app.context.window.fillInput('FL202610290001');
await flush();
const log = app.element('chatDom');
const fields = log.querySelector('.order-data-fields');
assert.ok(fields);
assert.equal(fields.children[1].textContent, '30.000 kg');
assert.equal(fields.children[3].innerHTML, '');
assert.equal(log.querySelector('.order-data-notice'), null);
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
assert.equal(app.context.window.__busy, false);
});
+202 -8
View File
@@ -45,6 +45,14 @@ class ApiTests(unittest.TestCase):
self.root = Path(self.directory.name)
self.app = self.application(self.root)
self.client = self.app.test_client()
from logistics_chat.order_data import import_records
with self.app.app_context():
connection = db.connect()
import_records(connection, [{'tenant_id':'fixture', 'order_no':'15252125',
'status':'PROCESSING', 'country_code':'US', 'gross_weight_kg':'30',
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}]}])
connection.commit()
connection.close()
def application(self, root):
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
@@ -77,7 +85,7 @@ class ApiTests(unittest.TestCase):
for path in ('/', '/admin', '/admin/dashboard'):
parser = Assets()
parser.feed(self.client.get(path).get_data(as_text=True))
self.assertEqual(len(parser.urls), 3)
self.assertGreaterEqual(len(parser.urls), 3)
for url in parser.urls:
resource = self.client.get(url)
self.assertEqual(resource.status_code, 200, url)
@@ -91,15 +99,17 @@ class ApiTests(unittest.TestCase):
self.assertEqual(events[-1], {'d': 1})
self.assertEqual(events[0]['card']['no'], '15252125')
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(len(messages), 2)
self.assertTrue(messages[1]['content'].startswith('__CARD__'))
self.assertEqual(len(messages), 3)
self.assertTrue(messages[-1]['content'].startswith('__CARD__'))
self.assertEqual(self.client.get('/api/get_history').json[0]['title'], '15252125')
self.assertEqual(self.client.delete(f'/api/del_history?id={cid}').json, {'ok': True})
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json, [])
def test_empty_image_and_missing_key_streams(self):
self.assertIn('e', frames(self.client.post('/stream_chat', json={'msg': ''}))[0])
self.assertIn('收到您的图片', frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))[0]['t'])
image_reply = frames(self.client.post('/stream_chat', json={'msg': '[图片]/uploads/test.png'}))
self.assertIn('收到您的图片', image_reply[0]['t'])
self.assertEqual(image_reply[-1], {'d': 1})
self.assertIn('API Key', frames(self.client.get('/stream_chat?msg=hello'))[0]['e'])
def test_attachment_filename_is_not_used_as_a_tracking_number(self):
@@ -110,11 +120,175 @@ class ApiTests(unittest.TestCase):
cid = self.conversation()
events = frames(self.client.post('/stream_chat', json={'msg': '查询15252125 ' + attachment, 'conv_id': cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[0]['content'], '查询15252125 ' + attachment)
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
def test_image_attachments_do_not_swallow_order_questions_and_are_saved(self):
cid = self.conversation()
image = '[图片]/uploads/20261029100145_6787.png'
events = frames(self.client.post('/stream_chat', json={'msg':'查询15252125 '+image, 'conv_id':cid}))
self.assertEqual(events[0]['card']['no'], '15252125')
events = frames(self.client.post('/stream_chat', json={'msg':image, 'conv_id':cid}))
self.assertIn('收到您的图片', events[0]['t'])
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertEqual(messages[-2]['content'], image)
self.assertIn('收到您的图片', messages[-1]['content'])
def test_bad_chat_payloads_produce_readable_errors_without_calling_model(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
invalid = ([1], 'hello', {'msg':7}, {'msg':['test']}, {'msg':'物流咨询', 'conv_id':{}},
{'msg':'物流咨询', 'conv_id':-1}, {'msg':'物流咨询', 'conv_id':1.5}, {'msg':'物流咨询','conv_id':False},
{'msg':'物流咨询','conv_id':999999999999999999999999},
{'msg':'物流咨询','conv_id':'²'}, {'msg':'物流咨询','conv_id':'9'*5000})
with patch('logistics_chat.ai_client.post_completion') as model:
for index, payload in enumerate(invalid):
with self.subTest(case=index):
events = frames(self.client.post('/stream_chat', json=payload))
self.assertIn('e', events[0])
model.assert_not_called()
def test_deleted_conversations_close_human_sessions_and_reject_new_chat_messages(self):
cid = self.conversation()
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026,10,29,12)
sid = self.client.post('/api/human/request',json={'conv_id':cid}).json['session_id']
self.client.post('/api/human/send',json={'sid':sid,'content':'原会话内容'})
self.client.delete(f'/api/del_history?id={cid}')
self.assertEqual(self.client.get('/api/human/queue').json, [])
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'15252125','conv_id':cid}))
self.assertIn('已删除',events[0]['e'])
model.assert_not_called()
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026,10,29,12)
self.assertEqual(self.client.post('/api/human/request',json={'conv_id':cid}).status_code,404)
def test_uk_quotes_and_followups_keep_the_uk_rates(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid = self.conversation()
for message, expected in (('30kg英国空运报价','英国空运专线'), ('那海运呢?','英国海运'),
('那空运呢?','英国空运专线'), ('那50公斤呢?','英国空运专线')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
prompt = model.call_args.args[0]['messages'][0]['content']
self.assertIn(expected,prompt)
self.assertNotIn('欧洲空运专线',prompt)
self.assertNotIn('欧洲海运快线',prompt)
def test_us_quotes_never_reuse_european_price_tables(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'30KG发美国FBA仓,海运报价','conv_id':self.conversation()}))
self.assertIn('目的地',events[0]['t'])
self.assertIn('人工',events[0]['t'])
model.assert_not_called()
def test_mixed_country_codes_select_the_correct_rates(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for message, channel, amount in (('30kg发UK海运报价','英国海运','384'),
('30kg发uk海运报价','英国海运','384'),
('30kg发GB海运报价','英国海运','384'),
('30kg发DE仓海运报价','欧洲海运','369')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion', return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
events=frames(self.client.post('/stream_chat',json={'msg':message}))
self.assertEqual(events[-1],{'d':1})
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(channel,prompt)
self.assertIn(amount,prompt)
for message in ('30kg发US仓海运报价','30kg发us仓海运报价','30kg发USFBA仓海运报价',
'30kg发阿根廷海运报价','30kg海运报价'):
with self.subTest(message=message), patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':message}))
self.assertIn('目的',events[0]['t'])
model.assert_not_called()
def test_short_channel_followups_and_retries_keep_weight_and_region(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
cid=self.conversation()
sequence=(('30kg英国海运报价','英国海运','384'), ('海运呢','英国海运','384'),
('空运呢','英国空运','1,440'), ('50公斤呢','英国空运','2,400'))
for message, channel, amount in sequence:
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(channel,prompt)
self.assertIn(amount,prompt)
with patch('logistics_chat.ai_client.post_completion',side_effect=requests.exceptions.ConnectionError):
self.assertIn('e',frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))[0])
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model:
frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn('英国海运',prompt)
self.assertIn('640',prompt)
def test_complete_new_questions_do_not_inherit_an_old_quote(self):
from logistics_chat.chat_service import shipping_intent_with_context
cid=self.conversation()
with self.app.app_context():
db.save_message(cid,'user','30kg英国海运报价')
db.save_message(cid,'assistant','报价结果')
for message in ('新的一票50kg德国空运报价','空运理赔应该怎么办?','海运时效一般多久?'):
with self.subTest(message=message):
intent=shipping_intent_with_context(message,cid)
self.assertNotEqual(intent['region'],'uk')
db.save_message(cid,'user','空运理赔应该怎么办?')
self.assertIsNone(shipping_intent_with_context('海运呢',cid)['weight'])
def test_dimension_quotes_and_changes_do_not_become_order_identifiers(self):
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
cid=self.conversation()
for message, amount in (('30kg英国海运,100x100x100cm','1,817'), ('50x40x30cm呢','384'),
('1.2x0.8x0.6m呢','1,229'),
('那另外一票50kg德国海运','615')):
with self.subTest(message=message):
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
events=frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
self.assertEqual(events[-1],{'d':1})
self.assertTrue(model.called)
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn(amount,prompt)
if '另外一票' in message:
self.assertNotIn('体积重',prompt)
def test_destination_only_replies_continue_the_existing_quote(self):
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
cid=self.conversation()
self.assertIn('目的',frames(self.client.post('/stream_chat',json={'msg':'30kg海运报价','conv_id':cid}))[0]['t'])
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
frames(self.client.post('/stream_chat',json={'msg':'英国','conv_id':cid}))
prompt=model.call_args.args[0]['messages'][0]['content']
self.assertIn('英国海运',prompt)
self.assertIn('384',prompt)
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':'US仓','conv_id':cid}))
self.assertIn('尚未包含',events[0]['t'])
model.assert_not_called()
def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'}))
self.assertIn('未提供',events[0]['t'])
self.assertIn('按方费率',events[0]['t'])
model.assert_not_called()
def test_closed_human_sessions_cannot_be_reopened_by_a_late_accept(self):
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026,10,29,12)
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
self.client.post('/api/human/close',json={'sid':sid})
self.assertEqual(self.client.post('/api/human/accept',json={'sid':sid}).status_code,409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'],'closed')
def test_human_lifecycle_typing_and_read_receipts(self):
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
with patch('logistics_chat.human_service.datetime') as clock:
from datetime import datetime
clock.now.return_value = datetime(2026, 10, 29, 12)
sid = self.client.post('/api/human/request', json={'conv_id': self.conversation()}).json['session_id']
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'customer', 'content': 'hello'})
@@ -190,7 +364,7 @@ class ApiTests(unittest.TestCase):
db.save_message(cid, 'user', 'hello, "quoted"\nworld')
self.client.post('/api/feedback', json={'vote': 1, 'question': 'hello', 'answer': 'world'})
self.assertEqual(self.client.get('/api/admin/stats').json['good'], 1)
response = self.client.get('/api/export_chat')
response = self.client.get('/api/export_chat?format=csv')
self.assertEqual(response.mimetype, 'text/csv')
self.assertIn('chat_export.csv', response.headers['Content-Disposition'])
self.assertIn('"hello, ""quoted""\nworld"', response.get_data(as_text=True))
@@ -218,13 +392,33 @@ class ApiTests(unittest.TestCase):
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 海运', 'conv_id': self.conversation()}))
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 德国海运', 'conv_id': self.conversation()}))
self.assertTrue(any('t' in event for event in events))
self.assertTrue(any('replace' in event for event in events))
self.assertTrue(any('suggest' in event for event in events))
self.assertEqual(events[-1], {'d': 1})
self.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)])
self.assertTrue(streamed.closed)
self.assertTrue(all(response.closed for response in responses))
def test_incomplete_model_streams_are_reported_and_saved_as_partial(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
for reason in (None, 'length'):
with self.subTest(reason=reason):
cid = self.conversation()
response = ModelResponse('这是已经生成的部分内容,需要保留以便用户了解中断前的信息。')
def lines():
yield ('data: '+json.dumps({'choices':[{'delta':{'content':response.content}, 'finish_reason':reason}]})).encode()
if reason:
yield b'data: [DONE]'
response.iter_lines = lines
with patch('logistics_chat.ai_client.post_completion', return_value=response):
events = frames(self.client.post('/stream_chat', json={'msg':'物流咨询', 'conv_id':cid}))
self.assertTrue(any('e' in event for event in events))
self.assertFalse(any(event.get('d') for event in events))
self.assertTrue(response.closed)
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
self.assertIn('回答中断', messages[-1]['content'])
def test_stream_network_and_model_errors(self):
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
+7
View File
@@ -91,6 +91,13 @@ class ConfigurationTests(unittest.TestCase):
self.assertIs(response, sentinel.response)
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key')
def test_order_scope_is_fixed_by_server_file_or_environment(self):
self.write_env('ORDER_DATA_TENANT_ID=tenant-file\n')
os.environ.pop('ORDER_DATA_TENANT_ID', None)
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-file')
os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment'
self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment')
if __name__ == '__main__':
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Remote dev behavior on the modular backend and the light workspace."""
from datetime import datetime
import io
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpyxl import load_workbook
from logistics_chat import create_app, db
class DevIntegrationTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
root = Path(directory.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()
def session(self):
cid = self.client.post('/api/new_chat').json['id']
with patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 31, 8)
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
return result['session_id']
def test_new_conversation_persists_one_welcome_before_user_message(self):
data = self.client.post('/api/new_chat').json
self.assertIn('欢迎', data['welcome'])
self.client.post('/stream_chat', json={'msg': '15252125', 'conv_id': data['id']}, buffered=True)
messages = self.client.get('/api/get_messages?conv_id='+str(data['id'])).json
self.assertEqual([message['role'] for message in messages], ['assistant', 'user', 'assistant'])
self.assertEqual(messages[0]['content'], data['welcome'])
def test_daily_service_hours_and_offline_ticket_on_fresh_database(self):
cid = self.client.post('/api/new_chat').json['id']
for hour, open_now in ((7, False), (8, True), (21, True), (22, False)):
with self.subTest(hour=hour), patch('logistics_chat.human_service.datetime') as clock:
clock.now.return_value = datetime(2026, 10, 31, hour)
result = self.client.post('/api/human/request', json={'conv_id': cid}).json
self.assertEqual(result['ok'], open_now)
if not open_now:
self.assertTrue(result['closed'])
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
sid = self.session()
self.client.post('/api/human/accept', json={'sid': sid})
self.client.post('/api/human/note', json={'sid': sid, 'note': '请跟进派送'})
self.client.post('/api/human/send', json={'sid': sid, 'side': 'agent', 'content': '正在处理'})
self.client.post('/api/human/typing', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).json, {'ok': True})
self.assertEqual(self.client.get('/api/human/active').json, [])
self.assertEqual(self.client.get('/api/human/queue').json[0]['id'], sid)
state = self.client.get(f'/api/human/poll?sid={sid}').json
self.assertEqual(state['note'], '请跟进派送')
self.assertFalse(state['typing'])
self.assertEqual(state['messages'][0]['content'], '正在处理')
self.assertEqual(state['messages'][-1]['side'], 'system')
self.assertEqual(self.client.post('/api/human/transfer', json={'sid': sid}).status_code, 409)
self.client.post('/api/human/accept', json={'sid': sid})
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
sid = self.session()
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
other_cid = self.client.post('/api/new_chat').json['id']
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'sid': 99999}).status_code, 404)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'side': 'system'}).status_code, 400)
self.client.post('/api/human/close', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/send', json=payload).status_code, 409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
def test_recall_targets_older_agent_message_and_is_idempotent(self):
sid = self.session()
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
self.client.post('/api/human/send', json={'sid': sid, 'side': side, 'content': text})
before = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
mid = before[0]['id']
for _ in range(2):
self.assertEqual(self.client.post('/api/human/recall', json={'mid': mid}).json, {'ok': True})
self.assertEqual(self.client.post('/api/human/recall', json={'mid': before[1]['id']}).status_code, 400)
self.assertEqual(self.client.post('/api/human/recall', json={'mid': 99999}).status_code, 404)
updates = self.client.get(f'/api/human/poll?sid={sid}&after={before[-1]["id"]}').json['messages']
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0]['recalled_id'], mid)
all_messages = self.client.get(f'/api/human/poll?sid={sid}').json['messages']
self.assertEqual(all_messages[0]['content'], '该消息已撤回')
self.assertEqual(all_messages[2]['content'], '最新回复')
def test_ratings_validate_stars_and_feed_admin_stats(self):
sid = self.session()
for stars in (0, 6, True, '5'):
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).status_code, 400)
self.assertEqual(self.client.post('/api/rate', json={'sid': 999, 'stars': 5}).status_code, 404)
for stars in (4, 5):
self.assertEqual(self.client.post('/api/rate', json={'sid': sid, 'stars': stars}).json, {'ok': True})
stats = self.client.get('/api/admin/stats').json
self.assertEqual((stats['rating_avg'], stats['rating_count']), (4.5, 2))
def test_excel_export_search_and_legacy_agent_schema(self):
cid = self.client.post('/api/new_chat').json['id']
with self.app.app_context():
db.save_message(cid, 'user', '=SUM(1,2)\n查询专线')
connection = db.connect()
connection.executescript('DROP TABLE agents; CREATE TABLE agents '
'(id INTEGER PRIMARY KEY, username TEXT, password TEXT, name TEXT);'
"INSERT INTO agents VALUES(7, 'demo', 'fixture-password', '测试客服');")
connection.commit()
connection.close()
db.init_db()
results = self.client.get('/api/search_chat?q=专线').json
self.assertEqual(len(results), 1)
self.assertIn('SUM', results[0]['content'])
self.assertEqual(self.client.get('/api/search_chat').json, {'results': []})
self.assertEqual(self.client.get('/api/agents/list').json, [{'id': 7, 'username': 'demo', 'name': '测试客服'}])
exported = self.client.get('/api/export_chat')
self.assertIn('chat_export.xlsx', exported.headers['Content-Disposition'])
workbook = load_workbook(io.BytesIO(exported.data))
sheet = workbook.active
self.assertEqual(sheet['C3'].value, '=SUM(1,2)\n查询专线')
self.assertEqual(sheet['C3'].data_type, 's')
self.assertEqual(sheet['B3'].value, '客户')
workbook.close()
def test_existing_schema_is_upgraded_without_losing_business_data(self):
with self.app.app_context():
connection = db.connect()
connection.executescript('DROP TABLE human_sessions; DROP TABLE human_messages;'
'CREATE TABLE human_sessions(id INTEGER PRIMARY KEY, conv_id INTEGER, status TEXT, ctime TEXT);'
'CREATE TABLE human_messages(id INTEGER PRIMARY KEY, session_id INTEGER, side TEXT, content TEXT, ctime TEXT);'
"INSERT INTO human_sessions VALUES(1, 1, 'active', 'now');"
"INSERT INTO human_messages VALUES(1, 1, 'agent', '保留的回复', 'now');"
"INSERT INTO agents VALUES('fixture-agent', 'fixture-password', 'Fixture Agent');")
connection.commit()
connection.close()
db.init_db()
db.init_db()
state = self.client.get('/api/human/poll?sid=1').json
self.assertEqual(state['messages'][0]['content'], '保留的回复')
self.assertEqual(state['note'], '')
self.assertEqual(self.client.get('/api/agents/list').json[0]['username'], 'fixture-agent')
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
def test_reply_workflows(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(
['node', '--test', 'tests/frontend/admin.test.cjs'], cwd=root,
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
text=True, encoding='utf-8', capture_output=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+48 -5
View File
@@ -35,7 +35,15 @@ elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
if sum(item[0] == 'nginx' and '-t' in item[1] for item in calls) == 1:
sys.exit(19)
elif name == 'curl' and '-w' in args:
calls = [json.loads(line) for line in log.read_text().splitlines()]
if scenario == 'reload-pending' and sum(item[0] == 'curl' and '-w' in item[1] for item in calls) == 1:
sys.exit(60)
if scenario == 'proxy-unsafe':
print('200', end='')
sys.exit(0)
print('401', end='')
elif name == 'sleep':
pass
'''
@@ -47,7 +55,8 @@ class GrayActivationTests(unittest.TestCase):
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
proxy = directory / 'proxy'
for path in (root / 'data', root / 'logs', root / 'releases', proxy):
auth = directory / 'baiyue-logistics-chat-gray-proxy'
for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth):
path.mkdir(parents=True, exist_ok=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
@@ -61,18 +70,22 @@ class GrayActivationTests(unittest.TestCase):
(root / 'current-release').write_text(str(previous) + '\n')
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
(proxy / '.baiyue-logistics-chat.htpasswd').write_text('previous-auth-hash\n')
(auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n')
(auth / 'access.htpasswd').write_text('previous-auth-hash\n')
unrelated = proxy / 'unrelated.conf'
unrelated.write_text('# unrelated site\n')
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
str(proxy), str(certificate), str(private_key), 'a'*40,
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'b'*64, 'sha256:' + 'c'*64)
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth))
release = Path(config.release)
write_release(release, config, 'fixture-model-key')
# Docker 26 and 29 may assign different IDs after an archive is loaded.
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
(release / 'site.htpasswd').write_text('new-auth-hash\n')
commands = directory / 'commands'
commands.mkdir()
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl'):
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl', 'sleep'):
command = commands / name
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
command.chmod(0o755)
@@ -92,19 +105,49 @@ class GrayActivationTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertIn('server_name chat.example.com', (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
self.assertEqual((auth / 'access.htpasswd').read_text(), 'new-auth-hash\n')
self.assertIn(str(auth / 'access.htpasswd'), (proxy / 'baiyue-logistics-chat-gray.conf').read_text())
self.assertEqual(auth.stat().st_mode & 0o777, 0o755)
self.assertEqual((auth / 'access.htpasswd').stat().st_mode & 0o777, 0o644)
def assert_rolled_back(self, scenario):
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertEqual((proxy / '.baiyue-logistics-chat.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
for name, args in calls), result.stderr)
def test_failed_application_restores_previous_release(self):
self.assert_rolled_back('app-fail')
def test_async_nginx_reload_waits_for_the_new_tls_workers(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('reload-pending')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertTrue(any(name == 'sleep' for name, args in calls))
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
self.assert_rolled_back('proxy-unsafe')
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_password_directory_owned_by_another_service_is_not_modified(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('unowned-auth')
self.assertNotEqual(result.returncode, 0)
auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy'
self.assertEqual((auth / '.project').read_text(), 'another-service\n')
self.assertEqual((auth / 'access.htpasswd').read_text(), 'previous-auth-hash\n')
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
self.assert_rolled_back('proxy-fail')
+79
View File
@@ -0,0 +1,79 @@
"""Verify the real remote image-load guard without contacting Docker or a host."""
import gzip
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Image transfer requires Linux Bash')
class GrayImageTransferTests(unittest.TestCase):
def run_load(self, scenario):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
release = root / 'releases/build'
release.mkdir(parents=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
archive = release / 'images.tar.gz'
archive.write_bytes(gzip.compress(b'fixture-image-archive'))
checksum = hashlib.sha256(archive.read_bytes()).hexdigest()
if scenario == 'corrupt':
archive.write_bytes(archive.read_bytes() + b'changed-in-transit')
commands = directory / 'commands'
commands.mkdir()
docker = commands / 'docker'
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, sys
args = sys.argv[1:]
with pathlib.Path(os.environ['FAKE_LOG']).open('a') as output:
output.write(' '.join(args) + '\\n')
if args == ['load']:
assert sys.stdin.buffer.read() == b'fixture-image-archive'
elif args[:2] == ['image', 'inspect']:
if args[3] == '{{.Id}}':
print('sha256:' + ('b' if '-gray:' in args[-1] else 'c') * 64)
else:
print(('e' if os.environ['FAKE_SCENARIO'] == 'wrong-source' else 'a') * 40)
else:
sys.exit(3)
''')
docker.chmod(0o755)
publish = (Path(__file__).resolve().parents[1] / 'deploy/publish-gray.sh').read_text()
remote = publish.split("<<'LOAD'\n", 1)[1].split('\nLOAD\n', 1)[0]
log = directory / 'commands.log'
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
FAKE_LOG=str(log), FAKE_SCENARIO=scenario)
result = subprocess.run(['bash', '-s', '--', str(root), str(release), checksum,
'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
'baiyue-logistics-chat-certbot:' + 'a' * 12 + '-b7', 'a' * 40],
input=remote, env=environment, capture_output=True, text=True, timeout=15)
return result, release, log
def test_verified_archive_records_target_ids_and_removes_archive(self):
result, release, log = self.run_load('success')
self.assertEqual(result.returncode, 0, result.stderr)
receipt = (release / 'loaded-image-ids.env').read_text()
self.assertIn('CHAT_IMAGE_ID=sha256:' + 'b' * 64, receipt)
self.assertIn('CERTBOT_IMAGE_ID=sha256:' + 'c' * 64, receipt)
self.assertFalse((release / 'images.tar.gz').exists())
def test_modified_archive_is_rejected_before_docker_load(self):
result, release, log = self.run_load('corrupt')
self.assertNotEqual(result.returncode, 0)
self.assertFalse(log.exists())
self.assertFalse((release / 'loaded-image-ids.env').exists())
def test_wrong_source_cannot_create_a_verified_receipt(self):
result, release, log = self.run_load('wrong-source')
self.assertNotEqual(result.returncode, 0)
self.assertFalse((release / 'loaded-image-ids.env').exists())
if __name__ == '__main__':
unittest.main()
+192
View File
@@ -0,0 +1,192 @@
"""Local order snapshots, exact lookup and conversational order queries."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db, order_data
from logistics_chat.tracking import CARD_PREFIX, card_to_text
class OrderDataTests(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':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
self.context = self.app.app_context()
self.context.push()
self.addCleanup(self.context.pop)
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
'status':'PROCESSING', 'country_code':'US', 'transport_mode':'SEA', 'warehouse_code':'TEST1',
'order_role':'MASTER', 'package_count':3, 'gross_weight_kg':'30.000', 'volume_cbm':'0.2500',
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
self.import_rows([self.record])
def import_rows(self, rows, replace=False):
connection = db.connect()
try:
with connection:
return order_data.import_records(connection, rows, replace=replace)
finally:
connection.close()
def events(self, message, cid=None):
response = self.client.post('/stream_chat', json={'msg':message, 'conv_id':cid})
self.assertEqual(response.status_code, 200)
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
def test_identifiers_are_exact_case_insensitive_and_preserve_suffixes(self):
for value in ('fl202610290001', 'INB20261029001-01', 'shipment00208'):
self.assertEqual(order_data.lookup_order(value)[0]['order_no'], self.record['order_no'])
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
self.assertEqual(order_data.extract_reference(text), [])
events = self.events('帮我查询物流轨迹')
self.assertIn('订单号', events[0]['t'])
self.assertEqual(events[-1], {'d':1})
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
'FL202610290001 运费多少钱?'):
with self.subTest(message=message):
event = self.events(message)[0]
if '运费' in message:
self.assertIn('未记录实际运费', event['t'])
else:
self.assertEqual(event['card']['no'], self.record['order_no'])
def test_unknown_orders_never_produce_fake_tracking_or_call_model(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('查询 UNKNOWN20260001')
self.assertIn('未找到', events[0]['t'])
self.assertFalse(any('card' in event for event in events))
self.assertEqual(events[-1], {'d':1})
model.assert_not_called()
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('列出所有订单')
self.assertIn('请提供订单号', events[0]['t'])
model.assert_not_called()
def test_server_tenant_scope_and_ambiguous_identifiers(self):
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
self.assertIn('多个订单', self.events(self.record['order_no'])[0]['t'])
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
rows = order_data.lookup_order(self.record['order_no'])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['gross_weight_kg'], '30.000')
self.app.config['ORDER_DATA_TENANT_ID'] = 'unknown-tenant'
self.assertEqual(order_data.lookup_order(self.record['order_no']), [])
def test_manual_changes_are_visible_and_reimport_preserves_them_by_default(self):
connection = db.connect()
connection.execute("UPDATE order_test_data SET gross_weight_kg='42.000' WHERE order_no=?", (self.record['order_no'],))
connection.commit()
connection.close()
self.assertEqual(self.import_rows([self.record]), {'imported':0, 'preserved':1})
fields = self.events(self.record['order_no'])[0]['card']['fields']
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '42.000 kg')
self.import_rows([self.record], replace=True)
self.assertEqual(order_data.lookup_order(self.record['order_no'])[0]['gross_weight_kg'], '30.000')
def test_new_table_records_can_be_added_without_touching_source_database(self):
self.import_rows([{**self.record, 'order_no':'LOCAL20260002', 'shipment_id':'localship002'}])
self.assertEqual(self.events('查询 LOCAL20260002')[0]['card']['no'], 'LOCAL20260002')
def test_business_status_history_does_not_claim_delivery_or_invent_transport_events(self):
self.import_rows([{**self.record, 'status':'COMPLETED'}], replace=True)
card = self.events(self.record['order_no'])[0]['card']
self.assertEqual(card['status'], '头程订单已完成')
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
self.assertEqual([node['time'] for node in card['nodes']], ['2026-10-29 10:00','2026-10-28 10:00'])
self.assertTrue(all(not node['place'] for node in card['nodes']))
def test_children_do_not_reuse_parent_total_weight(self):
self.import_rows([{**self.record, 'order_no':'CHILD20260001-01', 'order_role':'CHILD',
'gross_weight_kg':None, 'package_count':None, 'volume_cbm':None,
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
card = self.events('CHILD20260001-01')[0]['card']
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
def test_followups_refresh_local_data_and_remain_within_conversation(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
connection = db.connect()
connection.execute("UPDATE order_test_data SET gross_weight_kg='45.000' WHERE order_no=?", (self.record['order_no'],))
connection.commit()
connection.close()
card = self.events('这票重量是多少?',cid)[0]['card']
self.assertEqual(card['no'], self.record['order_no'])
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
self.events('查询 UNKNOWN20260001',cid)
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
other_cid = self.client.post('/api/new_chat').json['id']
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
def test_new_shipping_questions_do_not_reuse_previous_order(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
with self.subTest(message=message):
self.assertIsNone(order_data.answer_query(message, cid))
db.save_message(cid, 'user', '美国海运费用怎么算?')
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
def test_short_order_followups_read_facts_without_calling_model(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
with patch('logistics_chat.ai_client.post_completion') as model:
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
with self.subTest(message=message):
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
model.assert_not_called()
def test_card_storage_and_model_context_contain_only_selected_facts(self):
card = self.events(self.record['order_no'])[0]['card']
text = card_to_text(CARD_PREFIX+json.dumps(card))
self.assertIn('30.000', text)
self.assertIn('US', text)
self.assertNotIn('测试', text)
self.assertNotIn('tenant_id', text)
self.assertNotIn('customer_name', json.dumps(card))
def test_invalid_history_is_safe_and_read_failure_is_explicit(self):
connection = db.connect()
connection.execute("UPDATE order_test_data SET history_json='invalid'")
connection.commit()
self.assertEqual(self.events(self.record['order_no'])[0]['card']['nodes'], [])
connection.execute('DROP TABLE order_test_data')
connection.commit()
connection.close()
self.assertIn('暂时无法读取', self.events(self.record['order_no'])[0]['t'])
def test_import_failure_rolls_back_partial_rows(self):
with self.assertRaises(ValueError):
self.import_rows([{**self.record,'order_no':'ROLLBACK20260001'}, {'order_no':''}])
self.assertEqual(order_data.lookup_order('ROLLBACK20260001'), [])
if __name__ == '__main__':
unittest.main()
+19
View File
@@ -40,6 +40,23 @@ class RuleTests(unittest.TestCase):
self.assertEqual(intent['channel'], 'sea')
self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000)
self.assertEqual(pricing.parse_shipping_intent('100 lbs air')['weight'], 45.36)
self.assertEqual(pricing.parse_shipping_intent('1.2 x 0.8 x 0.6 m 30kg海运')['dims'],[1.2,0.8,0.6])
self.assertEqual(pricing.parse_shipping_intent('长10.5宽20.5高30.5')['dims'],[10.5,20.5,30.5])
def test_shipping_regions_distinguish_country_codes_from_english_pronouns(self):
self.assertEqual(pricing.shipping_region('Give us a quote for 30kg to Germany'),'eu')
self.assertEqual(pricing.shipping_region('30kg air to US'),'unsupported')
self.assertEqual(pricing.shipping_region('30kg air to us'),'unsupported')
self.assertEqual(pricing.shipping_region('30kg to UK'),'uk')
self.assertEqual(pricing.shipping_region('30kg发UK海运'),'uk')
self.assertEqual(pricing.shipping_region('30kg发US仓海运'),'unsupported')
self.assertIsNone(pricing.shipping_region('talk about us and our work'))
self.assertIsNone(pricing.shipping_region('30kg for UKEURO warehouse'))
self.assertIsNone(pricing.shipping_region('2GB附件,30kg海运报价'))
self.assertTrue(pricing.is_shipping_destination_reply('英国的FBA仓'))
self.assertTrue(pricing.is_shipping_destination_reply('目的地是UK仓'))
self.assertFalse(pricing.is_shipping_destination_reply('英国VAT费用是什么'))
self.assertFalse(pricing.is_shipping_destination_reply('Give us a quote for 30kg to Germany'))
def test_warehouse_country_and_surcharge(self):
result = pricing.calc_warehouse_card(2, '海运', country='DE')
@@ -51,6 +68,8 @@ class RuleTests(unittest.TestCase):
self.assertIn('500', pricing.surcharge_reply(product, fee, 100))
self.assertEqual(pricing.check_island('07000')[1], 'IT')
self.assertIsNone(pricing.check_island('not a postcode'))
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', country='GB'))
self.assertIsNone(pricing.calc_warehouse_card(2, '海运', warehouse_key='unknown'))
def test_tracking_recognition_and_card_context(self):
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
+12
View File
@@ -117,6 +117,18 @@ class StorageTests(unittest.TestCase):
self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0)
connection.close()
def test_late_stream_writes_cannot_recreate_messages_for_deleted_conversations(self):
connection = db.connect()
cid = connection.execute("INSERT INTO chat_history(title,ctime) VALUES('temporary','now')").lastrowid
connection.commit()
db.save_message(cid,'user','发送中的问题')
connection.execute('DELETE FROM messages WHERE conv_id=?',(cid,))
connection.execute('DELETE FROM chat_history WHERE id=?',(cid,))
connection.commit()
db.save_message(cid,'assistant','迟到的模型回复')
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=?',(cid,)).fetchone()[0],0)
connection.close()
if __name__ == '__main__':
unittest.main()