Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e58dd86d5b | ||
|
|
aacdca6930 | ||
|
|
3f1cee8006 | ||
|
|
99c26a393a | ||
|
|
e051ab2e9b | ||
|
|
6a428bd468 | ||
|
|
ce445d0fb8 | ||
|
|
8444ded971 | ||
|
|
f4e87b0c02 | ||
|
|
1f66307bba | ||
|
|
a081966342 | ||
|
|
cded4238a9 | ||
|
|
3c3d273ee3 | ||
|
|
97162016ee | ||
|
|
167c888718 |
@@ -3,3 +3,15 @@
|
||||
# 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=
|
||||
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
|
||||
ASR_PROVIDER=baidu
|
||||
BAIDU_ASR_API_KEY=
|
||||
BAIDU_ASR_SECRET_KEY=
|
||||
# Optional absolute ffmpeg executable path. Empty = find ffmpeg on PATH. Docker includes it.
|
||||
FFMPEG_BIN=
|
||||
# Optional alternative: ASR_PROVIDER=dashscope, with a separate Alibaba Cloud Model Studio key.
|
||||
ASR_API_KEY=
|
||||
ASR_URL=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
|
||||
ASR_MODEL=qwen3-asr-flash
|
||||
|
||||
@@ -7,9 +7,11 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| Area | Files and search anchors |
|
||||
|---|---|
|
||||
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
|
||||
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
|
||||
| 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`: lifecycle, transfer, notes, recall; `logistics_chat/routes/human.py` |
|
||||
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
|
||||
@@ -51,7 +53,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` and `chat.test.cjs` when Node.js is available; no npm dependencies.
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, and `voice.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
ARG DEBIAN_MIRROR=https://deb.debian.org
|
||||
RUN case "$DEBIAN_MIRROR" in https://deb.debian.org|https://mirrors.tuna.tsinghua.edu.cn) ;; *) exit 2;; esac \
|
||||
&& sed -i "s|http://deb.debian.org|$DEBIAN_MIRROR|g" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 update \
|
||||
&& apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN python -m pip install --no-cache-dir -r requirements.txt \
|
||||
&& groupadd --gid 10001 baiyue \
|
||||
&& useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin baiyue \
|
||||
@@ -16,6 +22,7 @@ COPY logistics_chat/ ./logistics_chat/
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
|
||||
COPY scripts/import_order_test_data.py ./scripts/
|
||||
USER 10001:10001
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
|
||||
|
||||
Vendored
+24
-5
@@ -20,6 +20,12 @@ pipeline {
|
||||
if (!(params.PYTHON_BASE_IMAGE ==~ /[A-Za-z0-9][A-Za-z0-9._\/:@-]+/)) {
|
||||
error('Invalid Python base image reference')
|
||||
}
|
||||
if (!((params.ASR_PROVIDER ?: '') in ['', 'baidu'])) {
|
||||
error('Gray speech provider must be baidu or disabled')
|
||||
}
|
||||
if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) {
|
||||
error('Select a supported Debian mirror')
|
||||
}
|
||||
}
|
||||
sh '''set -eu
|
||||
for file in deploy/*.sh; do bash -n "$file"; done
|
||||
@@ -29,12 +35,12 @@ pipeline {
|
||||
}
|
||||
stage('Build and test image') {
|
||||
steps {
|
||||
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}"]) {
|
||||
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}", "DEBIAN_MIRROR=${params.DEBIAN_MIRROR ?: 'https://deb.debian.org'}"]) {
|
||||
sh '''set -eu
|
||||
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" -t "$CHAT_IMAGE-test" .
|
||||
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
|
||||
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" -t "$CHAT_IMAGE-test" .
|
||||
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CHAT_IMAGE" .
|
||||
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
|
||||
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CERTBOT_IMAGE" .
|
||||
'''
|
||||
}
|
||||
@@ -50,16 +56,29 @@ pipeline {
|
||||
withEnv([
|
||||
"PUBLIC_ORIGIN=${params.PUBLIC_ORIGIN}", "HOST_PORT=${params.HOST_PORT}",
|
||||
"GRAY_DEPLOY_DIR=${params.GRAY_DEPLOY_DIR}", "GRAY_PROXY_CONF_DIR=${params.GRAY_PROXY_CONF_DIR}",
|
||||
"GRAY_TLS_CERT_FILE=${params.GRAY_TLS_CERT_FILE}", "GRAY_TLS_KEY_FILE=${params.GRAY_TLS_KEY_FILE}"
|
||||
"GRAY_TLS_CERT_FILE=${params.GRAY_TLS_CERT_FILE}", "GRAY_TLS_KEY_FILE=${params.GRAY_TLS_KEY_FILE}",
|
||||
"ASR_PROVIDER=${params.ASR_PROVIDER ?: ''}"
|
||||
]) {
|
||||
withCredentials([
|
||||
sshUserPrivateKey(credentialsId: params.SSH_CREDENTIAL_ID, keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
|
||||
string(credentialsId: params.SSH_HOST_CREDENTIAL_ID, variable: 'TARGET_SSH_HOST'),
|
||||
file(credentialsId: params.SSH_KNOWN_HOSTS_CREDENTIAL_ID, variable: 'TARGET_SSH_KNOWN_HOSTS'),
|
||||
string(credentialsId: params.DEEPSEEK_CREDENTIAL_ID, variable: 'DEEPSEEK_API_KEY'),
|
||||
string(credentialsId: params.BAIDU_ASR_API_KEY_CREDENTIAL_ID, variable: 'BAIDU_ASR_API_KEY'),
|
||||
string(credentialsId: params.BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID, variable: 'BAIDU_ASR_SECRET_KEY'),
|
||||
usernamePassword(credentialsId: params.PREVIEW_AUTH_CREDENTIAL_ID, usernameVariable: 'PREVIEW_USERNAME', passwordVariable: 'PREVIEW_PASSWORD')
|
||||
]) {
|
||||
sh 'bash deploy/publish-gray.sh'
|
||||
script {
|
||||
if (params.IMPORT_ORDER_SAMPLES) {
|
||||
withCredentials([
|
||||
file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'),
|
||||
file(credentialsId: params.TRACKING_SNAPSHOT_CREDENTIAL_ID, variable: 'TRACKING_SNAPSHOT_FILE')
|
||||
]) {
|
||||
sh 'bash deploy/import-gray-order-samples.sh'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 天 |
|
||||
@@ -104,6 +107,10 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
|
||||
|
||||
### 配置说明
|
||||
|
||||
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
|
||||
|
||||
在根目录 `.env` 配置 `ASR_PROVIDER=baidu`、`BAIDU_ASR_API_KEY` 和 `BAIDU_ASR_SECRET_KEY`,使用已开通短语音识别的百度语音应用凭据,不能混用 OCR 或 DeepSeek 凭据。服务端须安装 FFmpeg 并加入 PATH,也可用 `FFMPEG_BIN` 指定可执行文件绝对路径;Docker 应用镜像已包含 FFmpeg。浏览器 WebM/MP4 录音通过内存管道转为 16 kHz 单声道 PCM,录音不写入上传目录或历史,只发送识别文字。修改后重启服务。可选的 `ASR_PROVIDER=dashscope` 使用 `.env.example` 中独立的 ASR 配置。
|
||||
|
||||
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
|
||||
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
|
||||
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
|
||||
@@ -132,7 +139,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
|
||||
@@ -238,13 +246,23 @@ data: {"d": 1}
|
||||
|
||||
## 数据库与运行数据
|
||||
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders` 和 `tickets` 九张表,并兼容已有数据,不预置账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。运单卡片以 `__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 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
||||
|
||||
+20
-3
@@ -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
|
||||
@@ -43,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
|
||||
@@ -55,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")
|
||||
@@ -70,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
|
||||
@@ -102,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"
|
||||
|
||||
+25
-7
@@ -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};
|
||||
@@ -112,14 +114,30 @@ server {{
|
||||
'''
|
||||
|
||||
|
||||
def write_release(directory, config, key):
|
||||
if not key.strip() or any(character in key for character in ('\r', '\n', '\x00')):
|
||||
raise ValueError('The model credential must be a nonempty single line')
|
||||
def runtime_environment(key, speech=None):
|
||||
fields = {'DEEPSEEK_API_KEY': key}
|
||||
speech = speech or {}
|
||||
provider = (speech.get('ASR_PROVIDER') or '').strip()
|
||||
if provider:
|
||||
if provider != 'baidu':
|
||||
raise ValueError('Gray speech provider must be baidu or disabled')
|
||||
fields.update(ASR_PROVIDER=provider,
|
||||
BAIDU_ASR_API_KEY=speech.get('BAIDU_ASR_API_KEY', ''),
|
||||
BAIDU_ASR_SECRET_KEY=speech.get('BAIDU_ASR_SECRET_KEY', ''))
|
||||
for value in fields.values():
|
||||
if not isinstance(value, str) or not value.strip() or any(c in value for c in ('\r', '\n', '\x00')):
|
||||
raise ValueError('Runtime credentials must be nonempty single lines')
|
||||
def literal(value):
|
||||
return "'" + value.strip().replace('\\', '\\\\').replace("'", "\\'") + "'"
|
||||
return ''.join(name + '=' + literal(value) + '\n' for name, value in fields.items())
|
||||
|
||||
|
||||
def write_release(directory, config, key, speech=None):
|
||||
runtime = runtime_environment(key, speech)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
(directory / 'release.env').write_text(config.release_environment(), encoding='utf-8')
|
||||
(directory / 'site.conf').write_text(config.nginx_config(), encoding='utf-8')
|
||||
escaped = key.strip().replace('\\', '\\\\').replace("'", "\\'")
|
||||
(directory / 'runtime.env').write_text("DEEPSEEK_API_KEY='" + escaped + "'\n", encoding='utf-8')
|
||||
(directory / 'runtime.env').write_text(runtime, encoding='utf-8')
|
||||
(directory / 'runtime.env').chmod(0o600)
|
||||
source = Path(__file__).resolve().parent
|
||||
shutil.copyfile(source / 'compose.gray.yml', directory / 'compose.yml')
|
||||
@@ -134,7 +152,7 @@ def main():
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
config = GrayConfig.from_environment(os.environ)
|
||||
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'])
|
||||
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'], os.environ)
|
||||
except (KeyError, ValueError) as error:
|
||||
parser.exit(2, 'Gray release configuration is incomplete or invalid.\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
set +x
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
: "${ORDER_SNAPSHOT_FILE:?}" "${TRACKING_SNAPSHOT_FILE:?}"
|
||||
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
|
||||
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
|
||||
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
|
||||
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
|
||||
root=$GRAY_DEPLOY_DIR
|
||||
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
|
||||
release="$root/releases/$release_id"
|
||||
order_sha=$(sha256sum "$ORDER_SNAPSHOT_FILE" | awk '{print $1}')
|
||||
tracking_sha=$(sha256sum "$TRACKING_SNAPSHOT_FILE" | awk '{print $1}')
|
||||
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
|
||||
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
|
||||
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
||||
|
||||
# Names are fixed; credentials and snapshot contents never enter the shell log.
|
||||
scp "${ssh_options[@]}" "$ORDER_SNAPSHOT_FILE" "$target:$release/order-input.json"
|
||||
scp "${ssh_options[@]}" "$TRACKING_SNAPSHOT_FILE" "$target:$release/tracking-input.json"
|
||||
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$order_sha" "$tracking_sha" <<'IMPORT'
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
root=$1
|
||||
release_id=$2
|
||||
source_commit=$3
|
||||
order_sha=$4
|
||||
tracking_sha=$5
|
||||
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
|
||||
[[ "$order_sha" =~ ^[a-f0-9]{64}$ && "$tracking_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
|
||||
release="$root/releases/$release_id"
|
||||
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray && "$(cat "$root/current-release")" == "$release" ]] || exit 2
|
||||
container=baiyue-logistics-chat-gray
|
||||
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
|
||||
printf '%s %s\n' "$order_sha" "$release/order-input.json" "$tracking_sha" "$release/tracking-input.json" | sha256sum --check --status
|
||||
input="$root/data/imports/$release_id"
|
||||
[[ ! -e "$input" ]] || { echo 'Existing import input requires inspection.' >&2; exit 2; }
|
||||
cleanup() {
|
||||
rm -f -- "$input/orders.json" "$input/tracking.json" "$release/order-input.json" "$release/tracking-input.json"
|
||||
rmdir -- "$input" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
|
||||
install -m 600 -o 10001 -g 10001 "$release/order-input.json" "$input/orders.json"
|
||||
install -m 600 -o 10001 -g 10001 "$release/tracking-input.json" "$input/tracking.json"
|
||||
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-order-import-"+sys.argv[1]+".db"); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before sample import.")' "$release_id"
|
||||
docker exec "$container" python scripts/import_order_test_data.py \
|
||||
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/$release_id/orders.json"
|
||||
docker exec "$container" python scripts/import_order_test_data.py \
|
||||
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/$release_id/tracking.json" --tracking-only
|
||||
echo 'Private snapshots imported with existing order fields and nonempty tracking preserved.'
|
||||
IMPORT
|
||||
+14
-6
@@ -26,7 +26,8 @@ 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`;人工申请每天 8:00–22:00 |
|
||||
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
|
||||
@@ -49,6 +50,7 @@ logistics_chat/
|
||||
├── pricing.py # 运费规则、解析、换算、附加费说明
|
||||
├── rates_extra.py # 扩展报价数据
|
||||
├── tracking.py # 单号识别、模拟轨迹、卡片协议
|
||||
├── order_data.py # 可编辑订单测试表、精确查询、快照导入与卡片事实
|
||||
├── ai_client.py # 模型 HTTP、压缩与追问
|
||||
├── chat_service.py # 问答编排与 SSE 帧
|
||||
├── history_service.py # 历史会话 CRUD
|
||||
@@ -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,15 +104,20 @@ tests/ # 按业务模块组织的回归
|
||||
|
||||
## 必须保留的接口与数据约定
|
||||
|
||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
|
||||
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
|
||||
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
|
||||
- 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`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
|
||||
- 历史卡片以 `__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` 访问。
|
||||
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
|
||||
- 计费档位、单位换算和舍入保持原样。`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,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
||||
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
|
||||
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
||||
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
|
||||
- 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
|
||||
+12
-1
@@ -19,6 +19,7 @@
|
||||
| `ENVIRONMENT` | 单选 `gray`,流水线拒绝其他值 |
|
||||
| `SOURCE_REF` | 本次发布的不可变标签或提交,也可明确选择开发分支 |
|
||||
| `PYTHON_BASE_IMAGE` | 已验证的官方 Python 3.12 基础镜像 |
|
||||
| `DEBIAN_MIRROR` | 默认 `https://deb.debian.org`;国内可选 `https://mirrors.tuna.tsinghua.edu.cn`,保留 Debian 签名校验 |
|
||||
| `PUBLIC_ORIGIN` | 独立 HTTPS 域名,不带子路径 |
|
||||
| `HOST_PORT` | 已确认空闲的宿主机回环端口,如 `29013` |
|
||||
| `GRAY_DEPLOY_DIR` | 专用目录,末级必须为 `gray-logistics-chat` |
|
||||
@@ -29,9 +30,17 @@
|
||||
| `SSH_KNOWN_HOSTS_CREDENTIAL_ID` | 已核实的 known_hosts 文件引用 |
|
||||
| `DEEPSEEK_CREDENTIAL_ID` | 本服务模型密钥的 Secret text 引用 |
|
||||
| `PREVIEW_AUTH_CREDENTIAL_ID` | 专用验收用户名和密码引用 |
|
||||
| `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 |
|
||||
| `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 |
|
||||
| `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 |
|
||||
| `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 |
|
||||
|
||||
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
|
||||
|
||||
语音运行配置只注入 `ASR_PROVIDER`、`BAIDU_ASR_API_KEY`、`BAIDU_ASR_SECRET_KEY`,与 DS 密钥一起写入权限 600 的 `runtime.env`。容器使用镜像内的 FFmpeg,不传本机 `FFMPEG_BIN`,也不使用 OCR 凭据。缺少任一百度凭据时拒绝生成发布配置。
|
||||
|
||||
私有订单样本不进入 Git、构建上下文或镜像。可在明确授权后临时上传为 Jenkins Secret file 并启用 `IMPORT_ORDER_SAMPLES`;发布脚本核验快照校验和与实际运行提交,在线备份聊天 SQLite,再调用 `scripts/import_order_test_data.py` 默认导入订单、以 `--tracking-only` 补充轨迹。两步均不使用 `--replace`,保留手工订单字段和已有非空轨迹。临时文件在导入后清除;验收后移除临时 Jenkins 文件凭据并关闭导入开关。此操作只访问聊天服务自己的 SQLite。
|
||||
|
||||
## 发布步骤
|
||||
|
||||
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
|
||||
@@ -58,9 +67,11 @@
|
||||
|
||||
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
|
||||
|
||||
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
|
||||
|
||||
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
|
||||
|
||||
新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
|
||||
新环境自动创建业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
|
||||
|
||||
## HTTPS 及续期
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ def create_app(config=None):
|
||||
from .config import ROOT_DIR, default_config
|
||||
from . import db, file_service, hooks
|
||||
from .routes import register_routes
|
||||
from .routes.speech import MemoryAudioRequest
|
||||
|
||||
app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'),
|
||||
static_folder=str(ROOT_DIR/'static'))
|
||||
app.request_class = MemoryAudioRequest
|
||||
app.config.update(default_config())
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
+107
-27
@@ -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': '收到您的图片,请描述一下您的问题。'}) + sse({'d': 1}), {})
|
||||
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):
|
||||
@@ -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'})
|
||||
|
||||
@@ -20,6 +20,15 @@ def default_config():
|
||||
'BACKUP_DIR': ROOT_DIR / 'backups',
|
||||
'LOG_DIR': ROOT_DIR / 'logs',
|
||||
'DEEPSEEK_API_KEY': (api_key or '').strip(),
|
||||
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
|
||||
'ASR_PROVIDER': str(os.environ.get('ASR_PROVIDER', local_config.get('ASR_PROVIDER')) or 'baidu').strip().lower(),
|
||||
'BAIDU_ASR_API_KEY': str(os.environ.get('BAIDU_ASR_API_KEY', local_config.get('BAIDU_ASR_API_KEY')) or '').strip(),
|
||||
'BAIDU_ASR_SECRET_KEY': str(os.environ.get('BAIDU_ASR_SECRET_KEY', local_config.get('BAIDU_ASR_SECRET_KEY')) or '').strip(),
|
||||
'FFMPEG_BIN': str(os.environ.get('FFMPEG_BIN', local_config.get('FFMPEG_BIN')) or '').strip(),
|
||||
'ASR_URL': str(os.environ.get('ASR_URL', local_config.get('ASR_URL')) or
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
|
||||
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').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,
|
||||
|
||||
@@ -126,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()))
|
||||
@@ -201,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] == '新对话'):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from .db import connect, now_str
|
||||
|
||||
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。'
|
||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入订单号、入仓码或 Shipment ID 可查订单,或点击下方快捷选项。'
|
||||
|
||||
|
||||
def new_chat():
|
||||
@@ -37,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()
|
||||
@@ -47,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()
|
||||
|
||||
@@ -14,6 +14,10 @@ def human_request(data):
|
||||
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]
|
||||
@@ -42,11 +46,20 @@ def human_poll(sid, after, typing_map):
|
||||
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()
|
||||
@@ -70,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):
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""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
|
||||
from .order_tracking import build_tracking
|
||||
|
||||
|
||||
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
|
||||
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
|
||||
'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
|
||||
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 '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
|
||||
source_updated_at TEXT, snapshot_at TEXT,
|
||||
UNIQUE(tenant_id, order_no)
|
||||
)''')
|
||||
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
|
||||
if 'tracking_json' not in columns:
|
||||
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
|
||||
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)
|
||||
tracking = row['tracking_json'] or []
|
||||
if isinstance(tracking, str): tracking = json.loads(tracking)
|
||||
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
|
||||
row['tracking_json'] = json.dumps(tracking, 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 import_tracking_records(connection, records, replace=False):
|
||||
"""Update tracking alone; never replace hand-edited order fields or add unknown orders."""
|
||||
init_order_table(connection)
|
||||
imported = preserved = missing = 0
|
||||
for record in records:
|
||||
if not isinstance(record, dict) or not record.get('tenant_id') or not record.get('order_no'):
|
||||
raise ValueError('Tracking record requires tenant_id and order_no')
|
||||
tracking = record.get('tracking_json')
|
||||
if isinstance(tracking, str): tracking = json.loads(tracking)
|
||||
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
|
||||
row = connection.execute('SELECT id,source_order_id,tracking_json FROM order_test_data '
|
||||
'WHERE tenant_id=? AND order_no=?',
|
||||
(str(record['tenant_id']), record['order_no'])).fetchone()
|
||||
if not row:
|
||||
missing += 1
|
||||
continue
|
||||
if not record.get('source_order_id') or str(record['source_order_id']) != str(row[1]):
|
||||
raise ValueError('Tracking source_order_id does not match local order')
|
||||
if not replace and row[2] not in (None, '', '[]'):
|
||||
preserved += 1
|
||||
continue
|
||||
connection.execute('UPDATE order_test_data SET tracking_json=? WHERE id=?',
|
||||
(json.dumps(tracking, ensure_ascii=False), row[0]))
|
||||
imported += 1
|
||||
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||
|
||||
|
||||
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)
|
||||
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||
'nodes':nodes, 'summary':summary}
|
||||
tracking = build_tracking(row.get('tracking_json'))
|
||||
if tracking:
|
||||
card.update(node_kind='logistics', nodes=tracking['nodes'],
|
||||
estimated_nodes=tracking['estimated_nodes'], tracking_status=tracking['status'])
|
||||
facts = tracking['nodes'][:10] + tracking['estimated_nodes'][:5]
|
||||
card['summary'] += '。物流状态:' + (tracking['status'] or '暂无实际节点') + '。'
|
||||
card['summary'] += ';'.join(f"{node['time']} {node['text']} {node['place']}" for node in facts)
|
||||
return card
|
||||
|
||||
|
||||
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)
|
||||
details = (
|
||||
r'(?:(?:(?:物流|运输)?(?:状态|进度|轨迹)|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
|
||||
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
|
||||
r'(?:呢|吗|呀|啊)?')
|
||||
tracking = (
|
||||
r'(?:(?:到哪(?:里)?|开船|到港|清关|提柜|还柜|签收)(?:了)?(?:呢|吗|没|了吗|没有)?|'
|
||||
r'(?:预计)?(?:什么时候|何时|多久)(?:能|会|可以)?(?:到|到达|到港|到仓|到货|送到)(?:呢|呀|啊)?|还要多久)')
|
||||
return bool(re.fullmatch(
|
||||
r'(?:那|那么|还有|请问|请|查一下|看一下|现在|目前)?(?:它的|订单的)?'
|
||||
r'(?:'+details+'|'+tracking+')', 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])}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Customer tracking derived from explicit order-linked facts, without inference."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# Maritime codes follow the firstleg standard; warehouse facts remain distinct.
|
||||
NODE_LABELS = {
|
||||
'DOMESTIC_RECEIVED': (0, '已入国内仓', '预计入国内仓'),
|
||||
'LOADING_COMPLETED': (5, '已完成装柜', '预计完成装柜'),
|
||||
'DEPARTED_WAREHOUSE': (10, '已离开国内仓库', '预计离开国内仓库'),
|
||||
'AT_ORIGIN_PORT': (20, '重箱已进入起运港或码头', '预计进入起运港或码头'),
|
||||
'LOADED_ON_VESSEL': (30, '已装船', '预计装船'),
|
||||
'DEPARTED_ORIGIN_PORT': (40, '船舶已离开起运港', '预计离开起运港'),
|
||||
'IN_TRANSIT': (50, '海运途中', '预计进入海运阶段'),
|
||||
'ARRIVED_DESTINATION_PORT': (60, '船舶已到目的港', '预计到达目的港'),
|
||||
'DISCHARGED_FROM_VESSEL': (70, '已卸船、未清关', '预计卸船'),
|
||||
'PICKUP_READY': (80, '已清关、可提柜', '预计可提柜'),
|
||||
'PICKED_UP': (90, '已提柜', '预计提柜'),
|
||||
'EMPTY_RETURNED': (100, '已还柜', '预计还柜'),
|
||||
}
|
||||
|
||||
|
||||
def parse_time(value):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
result = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
|
||||
if result.tzinfo:
|
||||
result = result.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return result
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def build_tracking(raw):
|
||||
"""Latest observation corrects a milestone; forecasts never advance progress."""
|
||||
try:
|
||||
facts = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(facts, list):
|
||||
return None
|
||||
actual, estimated = {}, {}
|
||||
for index, fact in enumerate(facts):
|
||||
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
|
||||
continue
|
||||
shipment = str(fact.get('shipment_key') or '')
|
||||
key = (shipment, fact['code'])
|
||||
observation = parse_time(fact.get('observed_at')) or parse_time(fact.get('created_at')) or datetime.min
|
||||
for field, target in (('actual_at', actual), ('estimated_at', estimated)):
|
||||
when = parse_time(fact.get(field))
|
||||
if when is None:
|
||||
continue
|
||||
candidate = (observation, index, when, fact)
|
||||
if key not in target or candidate[:2] > target[key][:2]:
|
||||
target[key] = candidate
|
||||
if not actual and not estimated:
|
||||
return None
|
||||
|
||||
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
|
||||
multiple = len(shipments) > 1
|
||||
progress = {}
|
||||
for (shipment, code), (_, _, when, fact) in actual.items():
|
||||
sequence = NODE_LABELS[code][0]
|
||||
if shipment not in progress or sequence > progress[shipment][0]:
|
||||
progress[shipment] = (sequence, code, fact)
|
||||
|
||||
def label_shipment(shipment, fact):
|
||||
if not multiple or not shipment:
|
||||
return ''
|
||||
return '货柜 ' + str(fact.get('shipment_no') or (shipments.index(shipment) + 1))
|
||||
|
||||
def node(shipment, code, value, forecast=False):
|
||||
_, _, when, fact = value
|
||||
place = str(fact.get('place') or '')
|
||||
scope = label_shipment(shipment, fact)
|
||||
return {'time': str(fact['estimated_at' if forecast else 'actual_at']),
|
||||
'text': NODE_LABELS[code][2 if forecast else 1],
|
||||
'place': ' · '.join(part for part in (scope, place) if part)}
|
||||
|
||||
nodes = [node(shipment, code, value) for (shipment, code), value in
|
||||
sorted(actual.items(), key=lambda item: (item[1][2], NODE_LABELS[item[0][1]][0]), reverse=True)]
|
||||
forecasts = [node(shipment, code, value, True) for (shipment, code), value in
|
||||
sorted(estimated.items(), key=lambda item: item[1][2])
|
||||
if (shipment, code) not in actual and NODE_LABELS[code][0] > progress.get(shipment, (-1,))[0]]
|
||||
# Multiple containers keep independent progress rather than claiming the whole order advanced.
|
||||
current = [(shipment, value) for shipment, value in sorted(progress.items()) if shipment or not shipments]
|
||||
if not current and '' in progress:
|
||||
current = [('', progress[''])]
|
||||
statuses = [':'.join(part for part in (label_shipment(shipment, value[2]), NODE_LABELS[value[1]][1]) if part)
|
||||
for shipment, value in current]
|
||||
return {'nodes': nodes[:50], 'estimated_nodes': forecasts[:20], 'status': ';'.join(statuses)}
|
||||
+47
-25
@@ -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 "实重:未提供"]
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from . import admin, business, chat, files, history, human, pages
|
||||
for module in (admin, business, chat, files, history, human, pages):
|
||||
from . import admin, business, chat, files, history, human, pages, speech
|
||||
for module in (admin, business, chat, files, history, human, pages, speech):
|
||||
app.register_blueprint(module.bp)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""HTTP adapters for short voice input; no audio is stored or added to history."""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from flask import Blueprint, Request, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
from .. import speech_service
|
||||
|
||||
|
||||
bp = Blueprint('speech', __name__)
|
||||
|
||||
|
||||
class MemoryAudioRequest(Request):
|
||||
def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None):
|
||||
if self.path == '/api/speech/transcribe':
|
||||
return BytesIO()
|
||||
return super()._get_file_stream(total_content_length, content_type, filename, content_length)
|
||||
|
||||
|
||||
@bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
@bp.get('/api/speech/config')
|
||||
def speech_config():
|
||||
return {'enabled': speech_service.speech_enabled(), 'max_seconds': speech_service.MAX_DURATION_SECONDS,
|
||||
'max_bytes': speech_service.MAX_AUDIO_BYTES}, {'Cache-Control': 'no-store'}
|
||||
|
||||
|
||||
@bp.post('/api/speech/transcribe')
|
||||
def speech_transcribe():
|
||||
request.max_content_length = speech_service.MAX_AUDIO_BYTES + 64 * 1024
|
||||
if not speech_service.speech_enabled():
|
||||
return {'ok': False, 'error': speech_service.UNAVAILABLE}, 503
|
||||
try:
|
||||
files = request.files
|
||||
try:
|
||||
return speech_service.transcribe_recording(files.get('audio'), request.form.get('duration'),
|
||||
request.form.get('language'))
|
||||
finally:
|
||||
for _, upload in files.items(multi=True):
|
||||
upload.close()
|
||||
except RequestEntityTooLarge:
|
||||
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Bounded, in-memory recording transcription; separate from text chat credentials."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
from .config import get_setting
|
||||
|
||||
|
||||
MAX_AUDIO_BYTES = 5 * 1024 * 1024
|
||||
MAX_DURATION_SECONDS = 60
|
||||
UNAVAILABLE = '语音输入暂不可用,请使用文字输入。'
|
||||
|
||||
|
||||
class SpeechFailure(Exception):
|
||||
def __init__(self, message, status=502):
|
||||
self.message, self.status = message, status
|
||||
|
||||
|
||||
def ffmpeg_binary():
|
||||
configured = get_setting('FFMPEG_BIN')
|
||||
return str(configured) if configured and Path(configured).is_file() else shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def speech_enabled():
|
||||
provider = get_setting('ASR_PROVIDER')
|
||||
if provider == 'baidu':
|
||||
return bool(get_setting('BAIDU_ASR_API_KEY') and get_setting('BAIDU_ASR_SECRET_KEY') and ffmpeg_binary())
|
||||
if provider != 'dashscope':
|
||||
return False
|
||||
try:
|
||||
url = urlsplit(get_setting('ASR_URL'))
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(get_setting('ASR_API_KEY') and get_setting('ASR_MODEL')
|
||||
and url.scheme == 'https' and url.hostname and not url.username and not url.password)
|
||||
|
||||
|
||||
def post_json(url, **options):
|
||||
response = requests.post(url, timeout=(5, 30), stream=True, allow_redirects=False, **options)
|
||||
try:
|
||||
if response.status_code != 200:
|
||||
raise SpeechFailure('语音转文字失败,请稍后重试。')
|
||||
data = bytearray()
|
||||
for chunk in response.iter_content(4096):
|
||||
data.extend(chunk)
|
||||
if len(data) > 64 * 1024:
|
||||
raise SpeechFailure('语音识别结果无效,请重试。')
|
||||
return json.loads(data)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def baidu_token():
|
||||
key, secret = get_setting('BAIDU_ASR_API_KEY'), get_setting('BAIDU_ASR_SECRET_KEY')
|
||||
identity = hashlib.sha256((key + '\0' + secret).encode()).hexdigest()
|
||||
lock = current_app.extensions.setdefault('speech_token_lock', threading.Lock())
|
||||
with lock:
|
||||
cached = current_app.extensions.get('speech_token')
|
||||
if cached and cached['identity'] == identity and cached['expires'] > time.time():
|
||||
return cached['token']
|
||||
data = post_json('https://aip.baidubce.com/oauth/2.0/token', data={
|
||||
'grant_type':'client_credentials', 'client_id':key, 'client_secret':secret})
|
||||
token = data.get('access_token')
|
||||
if not isinstance(token, str) or not token:
|
||||
raise SpeechFailure(UNAVAILABLE, 503)
|
||||
current_app.extensions['speech_token'] = {'identity':identity, 'token':token,
|
||||
'expires':time.time() + max(0, min(float(data.get('expires_in', 0)), 2592000) - 60)}
|
||||
return token
|
||||
|
||||
|
||||
def to_pcm(content):
|
||||
"""Decode browser WebM/MP4 in pipes to mono 16 kHz, without temporary audio files."""
|
||||
binary = ffmpeg_binary()
|
||||
if not binary:
|
||||
raise SpeechFailure(UNAVAILABLE, 503)
|
||||
result = subprocess.run([binary, '-hide_banner', '-loglevel', 'error', '-protocol_whitelist', 'pipe',
|
||||
'-i', 'pipe:0', '-map', '0:a:0', '-vn', '-sn', '-dn', '-ac', '1', '-ar', '16000',
|
||||
'-t', '61', '-f', 's16le', 'pipe:1'], input=content, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
|
||||
if result.returncode != 0 or len(result.stdout) < 12800:
|
||||
raise SpeechFailure('录音无法识别,请重新录制。', 422)
|
||||
if len(result.stdout) > 16000 * 2 * MAX_DURATION_SECONDS:
|
||||
raise SpeechFailure('录音超过 60 秒,请缩短后重试。', 400)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def baidu_transcribe(content, language):
|
||||
pcm = to_pcm(content)
|
||||
result = post_json('https://vop.baidu.com/server_api', json={'format':'pcm', 'rate':16000, 'channel':1,
|
||||
'cuid':'baiyue-logistics-chat', 'token':baidu_token(), 'dev_pid':1737 if language == 'en' else 1537,
|
||||
'speech':base64.b64encode(pcm).decode('ascii'), 'len':len(pcm)})
|
||||
code = result.get('err_no')
|
||||
if code == 3301:
|
||||
raise SpeechFailure('没有识别到文字,请靠近麦克风重试。', 422)
|
||||
if code != 0:
|
||||
current_app.logger.warning('Baidu ASR request failed: code=%s', code)
|
||||
raise SpeechFailure(UNAVAILABLE if code == 3302 else '语音转文字失败,请稍后重试。', 503 if code == 3302 else 502)
|
||||
parts = result.get('result')
|
||||
if not isinstance(parts, list) or any(not isinstance(part, str) for part in parts):
|
||||
raise SpeechFailure('语音识别结果无效,请重试。')
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
def audio_format(content, mime):
|
||||
"""Check container signatures, never trust the uploaded filename."""
|
||||
mime = mime.split(';', 1)[0].lower()
|
||||
if mime == 'audio/webm' and content.startswith(b'\x1aE\xdf\xa3'):
|
||||
return 'audio/webm'
|
||||
if mime in ('audio/mp4', 'audio/x-m4a') and content[4:8] == b'ftyp':
|
||||
return 'audio/mp4'
|
||||
if mime in ('audio/ogg', 'audio/opus') and content.startswith(b'OggS'):
|
||||
return 'audio/ogg'
|
||||
if mime in ('audio/wav', 'audio/x-wav') and content[:4] == b'RIFF' and content[8:12] == b'WAVE':
|
||||
return 'audio/wav'
|
||||
return None
|
||||
|
||||
|
||||
def transcribe_recording(upload, duration, language):
|
||||
if not speech_enabled():
|
||||
return {'ok': False, 'error': UNAVAILABLE}, 503
|
||||
if not upload:
|
||||
return {'ok': False, 'error': '没有收到录音,请重新按住说话。'}, 400
|
||||
try:
|
||||
duration = float(duration)
|
||||
except (TypeError, ValueError):
|
||||
duration = 0
|
||||
if not math.isfinite(duration) or not 0.4 <= duration <= MAX_DURATION_SECONDS + 1:
|
||||
return {'ok': False, 'error': '录音时长无效,请录制 1 至 60 秒语音。'}, 400
|
||||
content = upload.read(MAX_AUDIO_BYTES + 1)
|
||||
if len(content) > MAX_AUDIO_BYTES:
|
||||
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
|
||||
mime = audio_format(content, upload.mimetype)
|
||||
if len(content) < 16 or not mime:
|
||||
return {'ok': False, 'error': '录音格式不受支持,请使用文字输入或更换浏览器。'}, 415
|
||||
language = 'en' if language == 'en' else 'zh'
|
||||
try:
|
||||
if get_setting('ASR_PROVIDER') == 'baidu':
|
||||
text = baidu_transcribe(content, language)
|
||||
else:
|
||||
body = {'model': get_setting('ASR_MODEL'), 'stream': False,
|
||||
'messages': [{'role': 'user', 'content': [{'type': 'input_audio', 'input_audio': {
|
||||
'data': 'data:' + mime + ';base64,' + base64.b64encode(content).decode('ascii')}}]}],
|
||||
'asr_options': {'language': language, 'enable_itn': True}}
|
||||
result = post_json(get_setting('ASR_URL'), json=body,
|
||||
headers={'Authorization': 'Bearer ' + get_setting('ASR_API_KEY')})
|
||||
text = result['choices'][0]['message']['content']
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return {'ok': False, 'error': '没有识别到文字,请靠近麦克风重试。'}, 422
|
||||
text = text.strip()
|
||||
if len(text) > 2000:
|
||||
return {'ok': False, 'error': '识别文字过长,请缩短录音后重试。'}, 422
|
||||
return {'ok': True, 'text': text}, 200
|
||||
except requests.Timeout:
|
||||
return {'ok': False, 'error': '语音转文字超时,请重试。'}, 504
|
||||
except SpeechFailure as error:
|
||||
return {'ok': False, 'error': error.message}, error.status
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'ok': False, 'error': '录音处理超时,请缩短后重试。'}, 504
|
||||
except OSError:
|
||||
return {'ok': False, 'error': UNAVAILABLE}, 503
|
||||
except (requests.RequestException, ValueError, KeyError, IndexError, TypeError):
|
||||
return {'ok': False, 'error': '语音转文字失败,请稍后重试。'}, 502
|
||||
@@ -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 = [
|
||||
("已揽收", "义乌仓"),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Read order-linked warehouse and shipping facts into an explicit private snapshot.
|
||||
|
||||
Requires the optional PyMySQL package. This command never writes to the source DB.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def export_tracking(connection, orders):
|
||||
output = {}
|
||||
for order in orders:
|
||||
key = (str(order.get('tenant_id') or ''), str(order.get('source_order_id') or ''))
|
||||
if not all(value.isdecimal() for value in key) or not order.get('order_no'):
|
||||
raise ValueError('Source snapshot requires numeric tenant_id/source_order_id and order_no')
|
||||
if key in output:
|
||||
raise ValueError('Source snapshot contains duplicate order identities')
|
||||
output[key] = {key_name: order[key_name] for key_name in ('tenant_id', 'source_order_id', 'order_no')}
|
||||
output[key]['tracking_json'] = []
|
||||
ids = sorted({key[1] for key in output})
|
||||
for start in range(0, len(ids), 100):
|
||||
batch = ids[start:start + 100]
|
||||
placeholders = ','.join('%s' for _ in batch)
|
||||
# Every relation uses both source order identity and customer identity.
|
||||
queries = [
|
||||
(f'''SELECT o.tenant_id,o.id AS order_id, i.id AS fact_id,
|
||||
i.inbounded_at AS actual_at,i.updated_at AS observed_at
|
||||
FROM baiyue_order.ord_order o
|
||||
JOIN baiyue_firstleg.fl_inbound_order i ON i.order_id=o.id AND i.tenant_id=o.tenant_id
|
||||
WHERE o.deleted=0 AND i.deleted=0 AND i.inbounded_at IS NOT NULL
|
||||
AND o.id IN ({placeholders}) ORDER BY i.id''', 'inbound'),
|
||||
(f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,i.id AS fact_id,
|
||||
i.frozen_at AS actual_at,i.frozen_at AS observed_at,
|
||||
p.container_booking_id,b.container_no
|
||||
FROM baiyue_order.ord_order o
|
||||
JOIN baiyue_firstleg.fl_loading_plan_item i
|
||||
ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0
|
||||
JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
|
||||
WHERE o.deleted=0 AND i.frozen_at IS NOT NULL
|
||||
AND o.id IN ({placeholders}) ORDER BY i.id''', 'loading'),
|
||||
(f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,n.id AS fact_id,n.standard_node,
|
||||
n.actual_at,n.estimated_at,n.observed_at,n.created_at,n.location_name,
|
||||
n.container_booking_id,b.container_no
|
||||
FROM baiyue_order.ord_order o
|
||||
JOIN baiyue_firstleg.fl_loading_plan_item i
|
||||
ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0 AND i.frozen_at IS NOT NULL
|
||||
JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
|
||||
JOIN baiyue_firstleg.fl_carrier_schedule_node_fact n ON n.container_booking_id=b.id
|
||||
WHERE o.deleted=0 AND o.id IN ({placeholders}) ORDER BY n.id''', 'shipping'),
|
||||
]
|
||||
for sql, source in queries:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, batch)
|
||||
for row in cursor.fetchall():
|
||||
key = (str(row['tenant_id']), str(row['order_id']))
|
||||
if key not in output:
|
||||
continue
|
||||
fact = {'event_id': source + ':' + str(row['fact_id']),
|
||||
'code': {'inbound': 'DOMESTIC_RECEIVED', 'loading': 'LOADING_COMPLETED'}.get(source)
|
||||
or row['standard_node'],
|
||||
'shipment_key': str(row.get('container_booking_id') or ''),
|
||||
'shipment_no': row.get('container_no') or '',
|
||||
'place': row.get('location_name') or ''}
|
||||
for field in ('actual_at', 'estimated_at', 'observed_at', 'created_at'):
|
||||
value = row.get(field)
|
||||
fact[field] = value.isoformat(sep=' ') if value else None
|
||||
output[key]['tracking_json'].append(fact)
|
||||
return list(output.values())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--database-env', type=Path, required=True)
|
||||
parser.add_argument('--orders-snapshot', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if args.output.exists(): parser.error('Output already exists; choose a new private snapshot path')
|
||||
if not args.output.parent.is_dir(): parser.error('Output parent directory must exist')
|
||||
if not args.orders_snapshot.is_file() or args.orders_snapshot.stat().st_size > 10 * 1024 * 1024:
|
||||
parser.error('Orders snapshot must be an existing JSON file smaller than 10 MB')
|
||||
orders = json.loads(args.orders_snapshot.read_text(encoding='utf-8-sig'))
|
||||
if isinstance(orders, dict): orders = orders.get('orders')
|
||||
if not isinstance(orders, list) or not orders or len(orders) > 3000:
|
||||
parser.error('Orders snapshot must contain 1 to 3000 orders')
|
||||
import pymysql
|
||||
from dotenv import dotenv_values
|
||||
config = dotenv_values(args.database_env, interpolate=False, encoding='utf-8-sig')
|
||||
required = ('CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD')
|
||||
if any(not config.get(key) for key in required): parser.error('Database env is missing connection settings')
|
||||
connection = pymysql.connect(host=config[required[0]], port=int(config[required[1]]),
|
||||
user=config[required[2]], password=config[required[3]], cursorclass=pymysql.cursors.DictCursor,
|
||||
connect_timeout=10, read_timeout=30, write_timeout=10, autocommit=False)
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute('SET SESSION TRANSACTION READ ONLY')
|
||||
cursor.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
|
||||
records = export_tracking(connection, orders)
|
||||
finally:
|
||||
connection.rollback()
|
||||
connection.close()
|
||||
args.output.write_text(json.dumps({'orders': records}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'orders': len(records), 'orders_with_tracking': sum(bool(row['tracking_json']) for row in records),
|
||||
'facts': sum(len(row['tracking_json']) for row in records)}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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, import_tracking_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')
|
||||
parser.add_argument('--tracking-only', action='store_true', help='Update only tracking for matching local orders')
|
||||
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:
|
||||
importer = import_tracking_records if args.tracking_only else import_records
|
||||
result = importer(connection, records, replace=args.replace)
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+29
-2
@@ -86,7 +86,17 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.recent-item:hover { color: var(--brand-text); }
|
||||
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
||||
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||
.composer-toolbar { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.composer-toolbar .hot-bar { flex: 1; min-width: 0; }
|
||||
.voice-toggle, .voice-hold { display: none; }
|
||||
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
|
||||
.voice-panel p { margin: 9px 0; font-size: 13px; }
|
||||
.voice-panel.is-cancelling { color: var(--danger); border-color: var(--danger); }
|
||||
.voice-wave { display: block; font-size: 26px; letter-spacing: 7px; }
|
||||
#voice-clock { font-size: 12px; color: var(--muted); }
|
||||
.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); }
|
||||
@@ -149,6 +159,14 @@ 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-details-toggle { display: none; }
|
||||
.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; }
|
||||
.order-tracking-status { margin: 0 0 12px; color: var(--brand-text); font-size: 13px; }
|
||||
.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); }
|
||||
@@ -200,6 +218,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.composer { width: calc(100% - 40px); }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
|
||||
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
|
||||
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
|
||||
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.header-kicker { font-size: 9px; }
|
||||
@@ -227,7 +249,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.hot-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; margin-bottom: 8px; }
|
||||
.hot-bar-label { display: none; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; }
|
||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.input-area { gap: 7px; padding: 7px; }
|
||||
#send-button { min-width: 57px; padding: 0 12px; }
|
||||
#upload-btn { flex-basis: 36px; height: 42px; }
|
||||
@@ -240,6 +262,11 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.track-text { grid-template-columns: 1fr; gap: 2px; }
|
||||
.track-place { grid-column: 1; }
|
||||
.robot-message.track-card { padding: 16px; }
|
||||
.track-no { font-size: 15px; }
|
||||
.track-head { align-items: flex-start; gap: 10px; }
|
||||
.track-no { min-width: 0; flex: 1; font-size: 15px; }
|
||||
.order-details-toggle { display: block; width: 100%; min-height: 44px; margin: 0 0 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
|
||||
.order-data-fields.is-collapsed { display: none; }
|
||||
.track-expand { min-height: 44px; }
|
||||
.msg-action-btn { min-height: 40px; }
|
||||
.to-bottom { right: 15px; }
|
||||
}
|
||||
|
||||
+601
-196
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
|
||||
var ChatVoice = {
|
||||
create(options){
|
||||
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
|
||||
let session = null, mode = false;
|
||||
const active = current => session === current;
|
||||
function stopTracks(current){
|
||||
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
function clearTimers(current){
|
||||
clearTimeout(current.limitTimer);
|
||||
clearTimeout(current.permissionTimer);
|
||||
clearInterval(current.clockTimer);
|
||||
}
|
||||
function render(){
|
||||
toggle.setAttribute('aria-pressed', String(mode));
|
||||
toggle.setAttribute('aria-label', text(mode ? 'voiceKeyboard' : 'voiceInput'));
|
||||
toggle.title = text(mode ? 'voiceKeyboard' : 'voiceInput');
|
||||
toggle.textContent = mode ? '⌨' : '🎙';
|
||||
inputArea.classList.toggle('voice-mode', mode);
|
||||
hold.textContent = text(session ? 'voiceRelease' : 'voiceHold');
|
||||
hold.classList.toggle('is-recording', Boolean(session));
|
||||
panel.hidden = !session;
|
||||
}
|
||||
function cancel(message){
|
||||
const current = session;
|
||||
session = null;
|
||||
if(current){
|
||||
current.controller.abort();
|
||||
clearTimers(current);
|
||||
if(current.recorder && current.recorder.state !== 'inactive'){
|
||||
try{ current.recorder.stop(); }catch(error){}
|
||||
}
|
||||
stopTracks(current);
|
||||
}
|
||||
render();
|
||||
if(message) status(text(message), true);
|
||||
}
|
||||
function setMode(on){
|
||||
cancel();
|
||||
mode = on;
|
||||
render();
|
||||
if(on && input.blur) input.blur();
|
||||
}
|
||||
function supported(){
|
||||
if(window.isSecureContext === false){ status(text('voiceHttps'), true); return false; }
|
||||
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder){
|
||||
status(text('voiceUnsupported'), true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function jsonRequest(current, url, init, timeout){
|
||||
const timer = setTimeout(function(){ current.timedOut = true; current.controller.abort(); }, timeout);
|
||||
try{
|
||||
const response = await fetch(url, Object.assign({}, init, {signal:current.controller.signal}));
|
||||
const data = await response.json();
|
||||
if(!response.ok) throw new Error(data.error || data.e || text('voiceFailed'));
|
||||
return data;
|
||||
}finally{ clearTimeout(timer); }
|
||||
}
|
||||
async function transcribe(current){
|
||||
if(!active(current)) return;
|
||||
if(!current.chunks.length){ cancel('voiceEmpty'); return; }
|
||||
current.phase = 'transcribing';
|
||||
label.textContent = text('voiceTranscribing');
|
||||
const blob = new Blob(current.chunks, {type:current.recorder.mimeType || current.mime});
|
||||
if(!blob.size || blob.size > current.maxBytes){ cancel('voiceTooLarge'); return; }
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, 'recording');
|
||||
form.append('duration', String(Math.min(60, current.duration / 1000)));
|
||||
form.append('language', options.language());
|
||||
try{
|
||||
const data = await jsonRequest(current, '/api/speech/transcribe', {method:'POST',body:form}, 40000);
|
||||
if(!active(current)) return;
|
||||
if(current.context !== options.context()){ cancel(); return; }
|
||||
if(!data.ok || typeof data.text !== 'string' || !data.text.trim()){
|
||||
cancel('voiceEmpty'); return;
|
||||
}
|
||||
session = null;
|
||||
render();
|
||||
await options.onText(data.text.trim());
|
||||
}catch(error){
|
||||
if(!active(current)) return;
|
||||
const message = current.timedOut ? text('voiceTimeout') : error.message || text('voiceFailed');
|
||||
cancel();
|
||||
status(message, true);
|
||||
}
|
||||
}
|
||||
function finish(current, cancelled){
|
||||
if(!active(current) || !current.holding) return;
|
||||
current.holding = false;
|
||||
if(cancelled || current.cancelled){ cancel(); status(text('voiceCancelled')); return; }
|
||||
if(current.phase !== 'recording'){ cancel('voiceTooShort'); return; }
|
||||
current.duration = Date.now() - current.startedAt;
|
||||
if(current.duration < 400){ cancel('voiceTooShort'); return; }
|
||||
current.phase = 'stopping';
|
||||
label.textContent = text('voiceTranscribing');
|
||||
clearTimers(current);
|
||||
try{ current.recorder.stop(); }
|
||||
catch(error){ cancel('voiceFailed'); }
|
||||
finally{ stopTracks(current); }
|
||||
}
|
||||
async function start(id, y){
|
||||
if(session || !mode) return;
|
||||
const reason = options.canStart();
|
||||
if(reason){ status(reason, true); return; }
|
||||
if(!supported()) return;
|
||||
const current = {id:id,startY:y,holding:true,chunks:[],bytes:0,phase:'preparing',
|
||||
controller:new AbortController(),context:options.context()};
|
||||
session = current;
|
||||
label.textContent = text('voicePreparing');
|
||||
clock.textContent = '';
|
||||
panel.classList.remove('is-cancelling');
|
||||
status('');
|
||||
render();
|
||||
try{
|
||||
const config = await jsonRequest(current, '/api/speech/config', {}, 5000);
|
||||
if(!active(current)) return;
|
||||
if(!config.enabled){ cancel('voiceUnavailable'); return; }
|
||||
current.maxBytes = Math.min(Number(config.max_bytes) || 5242880, 5242880);
|
||||
current.permissionTimer = setTimeout(function(){ if(active(current)) cancel('voiceTimeout'); }, 15000);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true},video:false});
|
||||
clearTimeout(current.permissionTimer);
|
||||
if(!active(current)){ stream.getTracks().forEach(track => track.stop()); return; }
|
||||
current.stream = stream;
|
||||
const formats = ['audio/webm;codecs=opus','audio/mp4','audio/webm','audio/ogg;codecs=opus'];
|
||||
current.mime = window.MediaRecorder.isTypeSupported
|
||||
? formats.find(mime => window.MediaRecorder.isTypeSupported(mime)) || '' : '';
|
||||
current.recorder = new window.MediaRecorder(stream, Object.assign({audioBitsPerSecond:64000},
|
||||
current.mime ? {mimeType:current.mime} : {}));
|
||||
current.recorder.ondataavailable = function(event){
|
||||
if(!active(current) || !event.data || !event.data.size) return;
|
||||
current.bytes += event.data.size;
|
||||
if(current.bytes > current.maxBytes){ cancel('voiceTooLarge'); return; }
|
||||
current.chunks.push(event.data);
|
||||
};
|
||||
current.recorder.onerror = function(){ if(active(current)) cancel('voiceFailed'); };
|
||||
current.recorder.onstop = function(){
|
||||
if(!active(current)) return;
|
||||
if(current.phase === 'stopping') transcribe(current);
|
||||
else cancel('voiceFailed');
|
||||
};
|
||||
current.recorder.start(250);
|
||||
current.startedAt = Date.now();
|
||||
current.phase = 'recording';
|
||||
label.textContent = text('voiceCancelTip');
|
||||
clock.textContent = '0 / 60 s';
|
||||
current.clockTimer = setInterval(function(){
|
||||
clock.textContent = Math.min(60, Math.floor((Date.now()-current.startedAt)/1000)) + ' / 60 s';
|
||||
}, 250);
|
||||
current.limitTimer = setTimeout(function(){ finish(current, false); }, 60000);
|
||||
}catch(error){
|
||||
if(!active(current)) return;
|
||||
const key = current.timedOut ? 'voiceTimeout'
|
||||
: ['NotAllowedError','PermissionDeniedError'].includes(error.name) ? 'voicePermission'
|
||||
: ['NotFoundError','DevicesNotFoundError'].includes(error.name) ? 'voiceNoMic' : 'voiceFailed';
|
||||
cancel(key);
|
||||
}
|
||||
}
|
||||
function move(id, y){
|
||||
if(!session || !session.holding || session.id !== id) return;
|
||||
session.cancelled = session.startY - y > 72;
|
||||
panel.classList.toggle('is-cancelling', session.cancelled);
|
||||
if(session.phase === 'recording') label.textContent = text(session.cancelled ? 'voiceCancelRelease' : 'voiceCancelTip');
|
||||
}
|
||||
toggle.onclick = function(){
|
||||
if(!mode && !supported()) return;
|
||||
setMode(!mode);
|
||||
if(!mode) input.focus();
|
||||
};
|
||||
if(window.PointerEvent){
|
||||
hold.addEventListener('pointerdown', function(event){
|
||||
if(event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
|
||||
start(event.pointerId, event.clientY);
|
||||
});
|
||||
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
|
||||
hold.addEventListener('pointerup', function(event){
|
||||
event.preventDefault();
|
||||
if(session && session.id === event.pointerId) finish(session, false);
|
||||
});
|
||||
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
|
||||
if(session && session.holding) finish(session, true);
|
||||
}));
|
||||
}else{
|
||||
hold.addEventListener('touchstart', function(event){
|
||||
event.preventDefault();
|
||||
if(event.touches.length !== 1) return;
|
||||
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
|
||||
}, {passive:false});
|
||||
hold.addEventListener('touchmove', function(event){
|
||||
event.preventDefault();
|
||||
for(const touch of event.changedTouches) move(touch.identifier, touch.clientY);
|
||||
}, {passive:false});
|
||||
hold.addEventListener('touchend', function(event){
|
||||
event.preventDefault();
|
||||
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
|
||||
}, {passive:false});
|
||||
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
|
||||
}
|
||||
hold.addEventListener('contextmenu', event => event.preventDefault());
|
||||
hold.addEventListener('keydown', function(event){
|
||||
if(![' ','Enter'].includes(event.key) || event.repeat) return;
|
||||
event.preventDefault(); start('keyboard', 0);
|
||||
});
|
||||
hold.addEventListener('keyup', function(event){
|
||||
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
|
||||
event.preventDefault(); finish(session, false);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
|
||||
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
|
||||
window.addEventListener('pagehide', function(){ cancel(); });
|
||||
render();
|
||||
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
|
||||
}
|
||||
};
|
||||
@@ -44,32 +44,36 @@
|
||||
<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 class="hot-bar">
|
||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||
<div class="composer-toolbar"><div class="hot-bar">
|
||||
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
||||
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||
</div>
|
||||
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></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 id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
<p id="composer-status" class="composer-status" role="status" hidden></p>
|
||||
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
|
||||
</footer>
|
||||
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
|
||||
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
||||
</main>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+601
-18
@@ -5,12 +5,15 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
async function customerPage(){
|
||||
const elements = new Map(), listeners = {}, intervals = [], calls = [];
|
||||
async function customerPage(pageOptions = {}){
|
||||
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||
let voiceOptions;
|
||||
class Element {
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
|
||||
this.listeners = {};
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -23,8 +26,10 @@ async function customerPage(){
|
||||
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(){}
|
||||
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(){}
|
||||
@@ -39,42 +44,87 @@ async function customerPage(){
|
||||
};
|
||||
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
|
||||
}
|
||||
closest(selector){ return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(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: () => [],
|
||||
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, 12])); } }
|
||||
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'},
|
||||
localStorage:{getItem:() => null, setItem(){}},
|
||||
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false})},
|
||||
setInterval: callback => { intervals.push(callback); return intervals.length; },
|
||||
clearInterval(){}, setTimeout(){}, clearTimeout(){},
|
||||
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:pageOptions.width || 1200,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
|
||||
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
|
||||
prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
clearInterval:id => intervals.delete(id),
|
||||
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
|
||||
clearTimeout:id => timers.delete(id),
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && JSON.parse(options.body)});
|
||||
const data = url === '/api/new_chat' ? {id:1, welcome:'welcome'}
|
||||
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
|
||||
: url === '/api/rate' ? {ok:true} : [];
|
||||
: ['/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']){
|
||||
for(const file of ['support.js', 'voice.js', 'chat.js']){
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||
if(file === 'voice.js'){
|
||||
const createVoice=context.ChatVoice.create;
|
||||
context.ChatVoice.create=options => {voiceOptions=options; return createVoice(options);};
|
||||
}
|
||||
}
|
||||
listeners.DOMContentLoaded();
|
||||
await flush();
|
||||
await context.window.showHuman();
|
||||
if(pageOptions.human !== false) await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, setState:value => { state=value; }, tick:async () => { intervals[0](); await flush(); }};
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
resize:async height => {
|
||||
if(context.window.visualViewport) context.window.visualViewport.height=height;
|
||||
(windowListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
resizeViewport:async (height,scale=1) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale});
|
||||
(viewportListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
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 () => {
|
||||
@@ -92,6 +142,205 @@ test('the final customer-visible reply is processed before closure and a rating
|
||||
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:'最新回复'}]});
|
||||
@@ -103,3 +352,337 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
|
||||
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);
|
||||
});
|
||||
|
||||
test('shipment cards separate forecasts from actual progress and retain both in history', async () => {
|
||||
const app = await customerPage({human:false, history:[{id:8,title:'订单轨迹',ctime:'2026-10-29'}]});
|
||||
const card = {kind:'order', no:'FL202610290001', status:'履约中', fields:[], node_kind:'logistics',
|
||||
tracking_status:'船舶已离开起运港', nodes:[{time:'2026-10-28 10:00',text:'船舶已离开起运港',place:'起运港'}],
|
||||
estimated_nodes:[{time:'2026-11-02 10:00',text:'预计到达目的港',place:'<img src=x onerror=bad()>'}],
|
||||
summary:'船舶已离开起运港,预计到达目的港2026-11-02'};
|
||||
app.hooks.messages = () => response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'2026-10-29 10:00'}]);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
const log = app.element('chatDom');
|
||||
assert.match(log.textContent, /物流轨迹/);
|
||||
assert.match(log.textContent, /预计节点/);
|
||||
assert.match(log.textContent, /预计到达目的港/);
|
||||
const timelines = log.querySelectorAll('.track-timeline');
|
||||
assert.equal(timelines.length, 2);
|
||||
assert.ok(timelines[0].querySelector('.track-node').classList.contains('active'));
|
||||
assert.equal(timelines[1].querySelector('.track-node').classList.contains('active'), false);
|
||||
assert.match(timelines[1].querySelector('.track-text').innerHTML, /<img/);
|
||||
assert.ok(log.querySelector('.msg-actions'));
|
||||
});
|
||||
|
||||
test('order details can expand without hiding the shipment timeline', async () => {
|
||||
const app = await customerPage({human:false,width:390});
|
||||
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
|
||||
fields:[{key:'shipment',value:'LONG-SHIPMENT-20261030001'}],
|
||||
nodes:[{time:'2026-10-29 10:00',text:'海运途中',place:''}],summary:'订单概要'};
|
||||
app.hooks['/stream_chat'] = () => streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('ORDER2026001');
|
||||
await flush();
|
||||
const log=app.element('chatDom'), fields=log.querySelector('.order-data-fields');
|
||||
const toggle=log.querySelector('.order-details-toggle');
|
||||
assert.equal(toggle.getAttribute('aria-expanded'),'false');
|
||||
assert.ok(fields.classList.contains('is-collapsed'));
|
||||
assert.match(log.querySelector('.track-timeline').textContent,/海运途中/);
|
||||
await app.click(toggle);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'),'true');
|
||||
assert.equal(fields.classList.contains('is-collapsed'),false);
|
||||
assert.match(fields.textContent,/LONG-SHIPMENT-20261030001/);
|
||||
await app.click(toggle);
|
||||
assert.ok(fields.classList.contains('is-collapsed'));
|
||||
});
|
||||
|
||||
test('viewport changes keep latest replies visible and preserve an older reading position', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
await app.context.window.fillInput('查询物流');
|
||||
await flush();
|
||||
const log=app.element('chatDom');
|
||||
Object.assign(log,{scrollHeight:1500,clientHeight:600,scrollTop:900});
|
||||
log.listeners.scroll();
|
||||
log.clientHeight=260;
|
||||
await app.resize(500);
|
||||
assert.equal(app.element('.chat-container').style.height,'500px');
|
||||
assert.equal(log.scrollTop,log.scrollHeight);
|
||||
log.scrollTop=100;
|
||||
log.listeners.scroll();
|
||||
await app.resizeViewport(450);
|
||||
assert.equal(app.element('.chat-container').style.height,'450px');
|
||||
assert.equal(log.scrollTop,100);
|
||||
await app.resizeViewport(400,2);
|
||||
assert.equal(app.element('.chat-container').style.height,'');
|
||||
assert.equal(log.scrollTop,100);
|
||||
});
|
||||
|
||||
test('clearing a sent draft restores a single-row composer instead of sizing to its placeholder', async () => {
|
||||
const app=await customerPage({human:false,width:320});
|
||||
const input=app.element('input-text');
|
||||
input.value='多行内容\n第二行';
|
||||
input.listeners.input();
|
||||
assert.equal(input.style.height,'140px');
|
||||
await app.context.window.fillInput(input.value);
|
||||
await flush();
|
||||
assert.equal(input.value,'');
|
||||
assert.equal(input.style.height,'');
|
||||
});
|
||||
|
||||
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
|
||||
for(const human of [false,true]){
|
||||
const app=await customerPage({human,width:390});
|
||||
await app.voiceOptions.onText('语音识别的物流问题');
|
||||
await flush();
|
||||
const call=app.calls.find(call=>call.url===(human ? '/api/human/send' : '/stream_chat'));
|
||||
assert.ok(call);
|
||||
assert.equal(human ? call.body.content : call.body.msg,'语音识别的物流问题');
|
||||
assert.match(app.element('chatDom').querySelector('.user-message').textContent,/语音识别的物流问题/);
|
||||
assert.equal(app.element('input-text').value,'');
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed voice send keeps the transcript editable and a newer draft is not auto-submitted', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
app.hooks['/api/new_chat']=()=>Promise.reject(new Error('offline'));
|
||||
await app.voiceOptions.onText('语音内容');
|
||||
assert.equal(app.element('input-text').value,'语音内容');
|
||||
assert.equal(app.element('.input-area').classList.contains('voice-mode'),false);
|
||||
app.element('input-text').value='新草稿';
|
||||
await app.voiceOptions.onText('后到的识别文字');
|
||||
assert.equal(app.element('input-text').value,'新草稿\n后到的识别文字');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0);
|
||||
});
|
||||
|
||||
test('a blocked retry retains its previous answer until a replacement can start', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('之前的问题');await flush();
|
||||
const log=app.element('chatDom'), retry=log.querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
|
||||
app.hooks['/api/upload']=options=>new Promise((resolve,reject)=>{
|
||||
options.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
|
||||
});
|
||||
app.element('upload-input').files=[{name:'pending.pdf'}];
|
||||
app.element('upload-input').onchange();await flush();
|
||||
await app.click(retry);
|
||||
assert.match(log.textContent,/测试 AI 回复/);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1);
|
||||
await app.runTimers(15000);
|
||||
await app.click(retry);await flush();
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2);
|
||||
assert.equal(log.querySelectorAll('.msg-action-btn').filter(button=>button.dataset.action==='retry').length,1);
|
||||
});
|
||||
|
||||
test('an AI regeneration is not forwarded to a human session', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
await app.context.window.fillInput('之前的问题');await flush();
|
||||
const retry=app.element('chatDom').querySelectorAll('.msg-action-btn').find(button=>button.dataset.action==='retry');
|
||||
await app.context.window.showHuman();await app.click(retry);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/human/send').length,0);
|
||||
assert.match(app.element('chatDom').textContent,/测试 AI 回复/);
|
||||
assert.match(app.element('composer-status').textContent,/先返回 AI/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
|
||||
function voicePage(options={}){
|
||||
const elements={}, timers=new Map(), docEvents={}, pageEvents={}, calls=[], messages=[], statuses=[];
|
||||
let now=1000, timerId=0, contextId='conversation-1', recorder, permissionCalls=0, trackStops=0;
|
||||
class Element {
|
||||
constructor(){ this.listeners={}; this.attributes={}; this.classes=new Set(); this.value='';
|
||||
this.classList={toggle:(name,on) => on ? this.classes.add(name) : this.classes.delete(name),remove:name=>this.classes.delete(name)}; }
|
||||
setAttribute(name,value){this.attributes[name]=value;}
|
||||
addEventListener(name,callback){this.listeners[name]=callback;}
|
||||
setPointerCapture(){}
|
||||
focus(){}
|
||||
blur(){}
|
||||
}
|
||||
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
||||
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
||||
class Recorder {
|
||||
constructor(source, settings){ recorder=this; this.state='inactive'; this.mimeType=settings.mimeType || 'audio/mp4'; }
|
||||
static isTypeSupported(type){return options.mp4 ? type==='audio/mp4' : type.startsWith('audio/webm');}
|
||||
start(){this.state='recording';}
|
||||
stop(){this.state='inactive'; this.ondataavailable({data:new Blob(['audio fixture'],{type:this.mimeType})}); this.onstop();}
|
||||
}
|
||||
class ClockDate extends Date {static now(){return now;}}
|
||||
const response = data => ({ok:true,json:async()=>data});
|
||||
const sandbox=vm.createContext({console,Blob,AbortController,Date:ClockDate,
|
||||
FormData:class{constructor(){this.values={};}append(key,value){this.values[key]=value;}},
|
||||
navigator:{mediaDevices:{getUserMedia:async()=>{permissionCalls++; if(options.permission) return options.permission(); return stream;}}},
|
||||
document:{hidden:false,addEventListener:(name,callback)=>{docEvents[name]=callback;}},
|
||||
window:{isSecureContext:options.secure!==false,MediaRecorder:options.unsupported ? undefined : Recorder,
|
||||
PointerEvent:options.touch ? undefined : function(){},addEventListener:(name,callback)=>{pageEvents[name]=callback;}},
|
||||
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},
|
||||
clearTimeout:id=>timers.delete(id),setInterval:()=>++timerId,clearInterval(){},
|
||||
fetch:async(url,init)=>{calls.push({url,init});
|
||||
if(url==='/api/speech/config') return response({enabled:options.enabled!==false,max_bytes:5242880,max_seconds:60});
|
||||
if(options.transcribe) return options.transcribe(init);
|
||||
return response({ok:true,text:'查询订单 FL20261030001'});
|
||||
},
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/voice.js'),'utf8'),sandbox);
|
||||
const voice=sandbox.ChatVoice.create({...elements,text:key=>key,status:(...args)=>statuses.push(args),
|
||||
language:()=> 'zh',context:()=>contextId,canStart:()=>options.reason || '',onText:async text=>messages.push(text)});
|
||||
const event=(extra={})=>({button:0,pointerId:7,clientY:300,preventDefault(){},...extra});
|
||||
return {voice,elements,calls,messages,statuses,stream,
|
||||
get permissionCalls(){return permissionCalls;},get trackStops(){return trackStops;},get recorder(){return recorder;},
|
||||
context:value=>{contextId=value;},
|
||||
open:()=>elements.toggle.onclick(),
|
||||
down:async()=>{elements.hold.listeners.pointerdown(event());await flush();},
|
||||
up:async(duration=1000)=>{now+=duration;elements.hold.listeners.pointerup(event());await flush();},
|
||||
move:y=>elements.hold.listeners.pointermove(event({clientY:y})),
|
||||
event,docEvents,pageEvents,sandbox,
|
||||
expire:async delay=>{for(const [id,timer] of [...timers]) if(timer.delay===delay){timers.delete(id);now+=delay;timer.callback();}await flush();},
|
||||
};
|
||||
}
|
||||
|
||||
test('hold and release transcribes audio and sends text once',async()=>{
|
||||
const app=voicePage();app.open();await app.down();
|
||||
assert.equal(app.recorder.state,'recording');
|
||||
assert.equal(app.elements.panel.hidden,false);
|
||||
await app.up();
|
||||
assert.deepEqual(app.messages,['查询订单 FL20261030001']);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,1);
|
||||
assert.ok(app.trackStops>0);
|
||||
assert.equal(app.elements.panel.hidden,true);
|
||||
await app.up();
|
||||
assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('sliding up cancels without uploading and supports another recording',async()=>{
|
||||
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
||||
assert.equal(app.messages.length,0);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,0);
|
||||
assert.ok(app.trackStops>0);
|
||||
await app.down();await app.up();
|
||||
assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('short presses and unavailable service do not upload recordings',async()=>{
|
||||
const app=voicePage();app.open();await app.down();await app.up(50);
|
||||
assert.equal(app.messages.length,0);
|
||||
assert.equal(app.statuses.at(-1)[0],'voiceTooShort');
|
||||
const disabled=voicePage({enabled:false});disabled.open();await disabled.down();
|
||||
assert.equal(disabled.permissionCalls,0);
|
||||
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
|
||||
});
|
||||
|
||||
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
|
||||
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
||||
app.open();await app.down();await app.up();grant(app.stream);await flush();
|
||||
assert.equal(app.messages.length,0);
|
||||
assert.ok(app.trackStops>0);
|
||||
assert.equal(app.voice.busy(),false);
|
||||
});
|
||||
|
||||
test('switching conversation or explicitly cancelling ignores a late transcript',async()=>{
|
||||
for(const cancelled of [false,true]){
|
||||
let resolve;const app=voicePage({transcribe:()=>new Promise(done=>{resolve=done;})});
|
||||
app.open();await app.down();await app.up();
|
||||
if(cancelled) app.voice.cancel(); else app.context('conversation-2');
|
||||
resolve({ok:true,json:async()=>({ok:true,text:'must not enter the new conversation'})});await flush();
|
||||
assert.equal(app.messages.length,0);
|
||||
assert.equal(app.voice.busy(),false);
|
||||
}
|
||||
});
|
||||
|
||||
test('permission denial and unsupported or insecure browsers leave typing available',async()=>{
|
||||
const denied=voicePage({permission:()=>Promise.reject(Object.assign(new Error(),{name:'NotAllowedError'}))});
|
||||
denied.open();await denied.down();
|
||||
assert.equal(denied.voice.busy(),false);
|
||||
assert.equal(denied.statuses.at(-1)[0],'voicePermission');
|
||||
for(const options of [{unsupported:true},{secure:false}]){
|
||||
const app=voicePage(options);app.open();
|
||||
assert.equal(app.elements.inputArea.classes.has('voice-mode'),false);
|
||||
assert.equal(app.permissionCalls,0);
|
||||
}
|
||||
});
|
||||
|
||||
test('pending drafts and busy chat do not request microphone access',async()=>{
|
||||
const app=voicePage({reason:'wait for current chat'});app.open();await app.down();
|
||||
assert.equal(app.permissionCalls,0);
|
||||
assert.equal(app.calls.length,0);
|
||||
assert.equal(app.statuses.at(-1)[0],'wait for current chat');
|
||||
});
|
||||
|
||||
test('maximum recording length stops the microphone and sends once',async()=>{
|
||||
const app=voicePage({mp4:true});app.open();await app.down();await app.expire(60000);
|
||||
assert.equal(app.messages.length,1);
|
||||
assert.ok(app.trackStops>0);
|
||||
assert.equal(app.calls.find(call=>call.url==='/api/speech/transcribe').init.body.values.audio.type,'audio/mp4');
|
||||
await app.up();assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('touch fallback and browser cancellation do not submit accidentally',async()=>{
|
||||
const app=voicePage({touch:true});app.open();
|
||||
app.elements.hold.listeners.touchstart(app.event({touches:[{identifier:1,clientY:300}]}));await flush();
|
||||
app.elements.hold.listeners.touchcancel();await flush();
|
||||
assert.equal(app.messages.length,0);
|
||||
assert.ok(app.trackStops>0);
|
||||
});
|
||||
|
||||
test('a stalled transcription times out and releases the recorder state',async()=>{
|
||||
const app=voicePage({transcribe:init=>new Promise((resolve,reject)=>{
|
||||
init.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
|
||||
})});app.open();await app.down();await app.up();await app.expire(40000);
|
||||
assert.equal(app.voice.busy(),false);
|
||||
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
|
||||
assert.equal(app.messages.length,0);
|
||||
});
|
||||
|
||||
test('an unanswered microphone permission prompt times out and a late stream is closed',async()=>{
|
||||
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
||||
app.open();await app.down();await app.expire(15000);
|
||||
assert.equal(app.voice.busy(),false);
|
||||
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
|
||||
grant(app.stream);await flush();
|
||||
assert.ok(app.trackStops>0);
|
||||
assert.equal(app.messages.length,0);
|
||||
});
|
||||
+190
-1
@@ -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',
|
||||
@@ -114,6 +122,167 @@ class ApiTests(unittest.TestCase):
|
||||
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||
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)
|
||||
with patch('logistics_chat.human_service.datetime') as clock:
|
||||
@@ -223,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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -67,6 +67,17 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
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', '最新回复')):
|
||||
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@@ -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,12 +70,13 @@ 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:' + 'd'*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.
|
||||
@@ -75,7 +85,7 @@ class GrayActivationTests(unittest.TestCase):
|
||||
(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)
|
||||
@@ -95,19 +105,33 @@ 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)
|
||||
@@ -115,6 +139,15 @@ class GrayActivationTests(unittest.TestCase):
|
||||
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')
|
||||
|
||||
|
||||
@@ -65,6 +65,32 @@ class GrayConfigTests(unittest.TestCase):
|
||||
write_release(path, config, 'fixture-key\nANOTHER_SETTING=bad')
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_baidu_credentials_only_enter_private_runtime_file(self):
|
||||
config = GrayConfig.from_environment(self.settings())
|
||||
speech = {'ASR_PROVIDER': 'baidu', 'BAIDU_ASR_API_KEY': 'fixture-${asr-key}',
|
||||
'BAIDU_ASR_SECRET_KEY': "fixture-asr-secret's", 'FFMPEG_BIN': r'C:\local\ffmpeg.exe'}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'release'
|
||||
write_release(path, config, 'fixture-model-key', speech)
|
||||
runtime = dotenv_values(path / 'runtime.env', interpolate=False)
|
||||
self.assertEqual(runtime['ASR_PROVIDER'], 'baidu')
|
||||
for key in ('BAIDU_ASR_API_KEY', 'BAIDU_ASR_SECRET_KEY'):
|
||||
self.assertEqual(runtime[key], speech[key])
|
||||
for file in path.iterdir():
|
||||
if file.name != 'runtime.env':
|
||||
self.assertNotIn(speech[key], file.read_text(encoding='utf-8'))
|
||||
self.assertNotIn('FFMPEG_BIN', runtime)
|
||||
|
||||
def test_incomplete_or_multiline_speech_credentials_are_rejected_before_writing(self):
|
||||
config = GrayConfig.from_environment(self.settings())
|
||||
for speech in ({'ASR_PROVIDER': 'baidu'}, {'ASR_PROVIDER': 'unknown'},
|
||||
{'ASR_PROVIDER': 'baidu', 'BAIDU_ASR_API_KEY': 'key', 'BAIDU_ASR_SECRET_KEY': 'secret\nOTHER=bad'}):
|
||||
with self.subTest(provider=speech['ASR_PROVIDER']), tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'release'
|
||||
with self.assertRaises(ValueError):
|
||||
write_release(path, config, 'fixture-key', speech)
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Run the real private-import guard against a disposable database and fake Docker."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from logistics_chat.order_data import import_records
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Private import requires Linux Bash')
|
||||
class GrayOrderImportTests(unittest.TestCase):
|
||||
def run_import(self, corrupt=False):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
directory = Path(temporary.name)
|
||||
root = directory / 'gray-logistics-chat'
|
||||
release_id = 'a' * 12 + '-b7'
|
||||
release = root / 'releases' / release_id
|
||||
release.mkdir(parents=True)
|
||||
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
||||
(root / 'current-release').write_text(str(release) + '\n')
|
||||
data = root / 'data'
|
||||
(data / 'backups').mkdir(parents=True)
|
||||
database = data / 'history.db'
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute('CREATE TABLE messages (content TEXT)')
|
||||
connection.execute("INSERT INTO messages VALUES ('keep chat history')")
|
||||
import_records(connection, [{'order_no': 'EXISTING1', 'source_order_id': '1',
|
||||
'tenant_id': 'test', 'gross_weight_kg': 'manual weight',
|
||||
'tracking_json': [{'manual': True}]}])
|
||||
orders = [{'order_no': 'EXISTING1', 'source_order_id': '1', 'tenant_id': 'test', 'gross_weight_kg': 'source weight'},
|
||||
{'order_no': 'NEW00002', 'source_order_id': '2', 'tenant_id': 'test'}]
|
||||
tracking = [{'order_no': row['order_no'], 'source_order_id': row['source_order_id'],
|
||||
'tenant_id': 'test', 'tracking_json': [{'code': 'IN_TRANSIT', 'actual_at': '2026-10-30'}]}
|
||||
for row in orders]
|
||||
hashes = []
|
||||
for name, records in [('order-input.json', orders), ('tracking-input.json', tracking)]:
|
||||
path = release / name
|
||||
path.write_text(json.dumps({'orders': records}))
|
||||
hashes.append(hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
if corrupt:
|
||||
(release / 'order-input.json').write_text('changed in transit')
|
||||
commands = directory / 'commands'
|
||||
commands.mkdir()
|
||||
docker = commands / 'docker'
|
||||
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, subprocess, sys
|
||||
args = sys.argv[1:]
|
||||
if args[0] == 'inspect':
|
||||
print('a'*40 if 'revision' in args[2] else 'baiyue-logistics-chat-gray')
|
||||
elif args[0] == 'exec':
|
||||
command = [sys.executable] + args[3:]
|
||||
command = [value.replace('/var/lib/baiyue-logistics-chat', os.environ['FAKE_DATA']) for value in command]
|
||||
if len(command) > 1 and command[1] != '-c':
|
||||
command[1] = str(pathlib.Path(os.environ['FAKE_SOURCE']) / command[1])
|
||||
sys.exit(subprocess.run(command).returncode)
|
||||
else:
|
||||
sys.exit(3)
|
||||
''')
|
||||
docker.chmod(0o755)
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
script = (source / 'deploy/import-gray-order-samples.sh').read_text()
|
||||
remote = script.split("<<'IMPORT'\n", 1)[1].split('\nIMPORT\n', 1)[0]
|
||||
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
|
||||
FAKE_DATA=str(data), FAKE_SOURCE=str(source))
|
||||
result = subprocess.run(['bash', '-s', '--', str(root), release_id, 'a' * 40, *hashes],
|
||||
input=remote, env=environment, capture_output=True, text=True, timeout=30)
|
||||
return result, data, release, release_id
|
||||
|
||||
def test_import_backs_up_and_preserves_manual_fields_tracking_and_chat_history(self):
|
||||
result, data, release, release_id = self.run_import()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
with sqlite3.connect(data / 'history.db') as connection:
|
||||
self.assertEqual(connection.execute('SELECT content FROM messages').fetchall(), [('keep chat history',)])
|
||||
row = connection.execute('SELECT gross_weight_kg,tracking_json FROM order_test_data WHERE order_no="EXISTING1"').fetchone()
|
||||
self.assertEqual(row[0], 'manual weight')
|
||||
self.assertEqual(json.loads(row[1]), [{'manual': True}])
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 2)
|
||||
row = connection.execute('SELECT tracking_json FROM order_test_data WHERE order_no="NEW00002"').fetchone()
|
||||
self.assertEqual(json.loads(row[0])[0]['code'], 'IN_TRANSIT')
|
||||
with sqlite3.connect(data / 'backups' / ('pre-order-import-' + release_id + '.db')) as backup:
|
||||
self.assertEqual(backup.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
|
||||
self.assertFalse((data / 'imports' / release_id).exists())
|
||||
self.assertFalse((release / 'order-input.json').exists())
|
||||
self.assertFalse((release / 'tracking-input.json').exists())
|
||||
|
||||
def test_corrupt_snapshot_is_rejected_before_database_changes(self):
|
||||
result, data, release, release_id = self.run_import(corrupt=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
with sqlite3.connect(data / 'history.db') as connection:
|
||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
|
||||
self.assertFalse(list((data / 'backups').iterdir()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""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_tracking_queries_and_followups_read_linked_facts_without_calling_model(self):
|
||||
self.import_rows([{**self.record, 'tracking_json': [
|
||||
{'code': 'DEPARTED_ORIGIN_PORT', 'actual_at': '2026-10-28 10:00', 'shipment_key': '1'},
|
||||
{'code': 'ARRIVED_DESTINATION_PORT', 'estimated_at': '2026-11-02 10:00', 'shipment_key': '1'},
|
||||
]}], replace=True)
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for message in (self.record['order_no']+' 物流轨迹', '到哪里了?', '那到港了吗?', '这票签收了吗?',
|
||||
'轨迹呢?', '现在到哪了?', '什么时候到港?', '预计什么时候到?', '还要多久?'):
|
||||
card = self.events(message, cid)[0]['card']
|
||||
self.assertEqual(card['node_kind'], 'logistics')
|
||||
self.assertEqual(card['tracking_status'], '船舶已离开起运港')
|
||||
self.assertEqual(card['nodes'][0]['text'], '船舶已离开起运港')
|
||||
self.assertEqual(card['estimated_nodes'][0]['text'], '预计到达目的港')
|
||||
self.assertIn('预计到达目的港', card_to_text(CARD_PREFIX+json.dumps(card)))
|
||||
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
|
||||
model.assert_not_called()
|
||||
self.assertIsNone(order_data.answer_query('美国30KG报价', cid))
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tracking facts preserve actual/estimated meaning, corrections and order identity."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
|
||||
from logistics_chat.order_data import import_records, import_tracking_records
|
||||
from logistics_chat.order_tracking import build_tracking
|
||||
|
||||
|
||||
class OrderTrackingTests(unittest.TestCase):
|
||||
def fact(self, code, actual=None, estimated=None, **extra):
|
||||
return {'code': code, 'actual_at': actual, 'estimated_at': estimated,
|
||||
'observed_at': '2026-10-29 12:00', 'shipment_key': 'container-1', **extra}
|
||||
|
||||
def test_estimates_do_not_advance_actual_progress_or_fill_missing_milestones(self):
|
||||
result = build_tracking([
|
||||
self.fact('DEPARTED_ORIGIN_PORT', '2026-10-25 10:00'),
|
||||
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-01 10:00'),
|
||||
])
|
||||
self.assertEqual(result['status'], '船舶已离开起运港')
|
||||
self.assertEqual([n['text'] for n in result['nodes']], ['船舶已离开起运港'])
|
||||
self.assertEqual(result['estimated_nodes'][0]['text'], '预计到达目的港')
|
||||
self.assertEqual(result['estimated_nodes'][0]['time'], '2026-11-01 10:00')
|
||||
|
||||
def test_latest_observation_can_correct_actual_time_backwards(self):
|
||||
result = build_tracking([
|
||||
self.fact('ARRIVED_DESTINATION_PORT', '2026-10-28 10:00'),
|
||||
self.fact('ARRIVED_DESTINATION_PORT', '2026-10-27 10:00', observed_at='2026-10-30 10:00'),
|
||||
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-31 10:00', observed_at='2026-10-31 10:00'),
|
||||
])
|
||||
self.assertEqual(len(result['nodes']), 1)
|
||||
self.assertEqual(result['nodes'][0]['time'], '2026-10-27 10:00')
|
||||
self.assertEqual(result['estimated_nodes'], [])
|
||||
|
||||
def test_delayed_earlier_milestone_and_stale_forecast_do_not_regress_progress(self):
|
||||
result = build_tracking([
|
||||
self.fact('PICKED_UP', '2026-10-28 10:00'),
|
||||
self.fact('AT_ORIGIN_PORT', '2026-10-29 10:00', observed_at='2026-10-30 10:00'),
|
||||
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-29 20:00'),
|
||||
])
|
||||
self.assertEqual(result['status'], '已提柜')
|
||||
self.assertEqual(len(result['nodes']), 2)
|
||||
self.assertEqual(result['estimated_nodes'], [])
|
||||
self.assertNotIn('签收', json.dumps(result, ensure_ascii=False))
|
||||
|
||||
def test_multiple_containers_keep_independent_progress_and_locations(self):
|
||||
result = build_tracking([
|
||||
self.fact('PICKED_UP', '2026-10-28 10:00', shipment_no='BOX-A', place='码头 A'),
|
||||
self.fact('IN_TRANSIT', '2026-10-27 10:00', shipment_key='container-2', shipment_no='BOX-B'),
|
||||
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00',
|
||||
shipment_key='container-2', shipment_no='BOX-B'),
|
||||
])
|
||||
self.assertEqual(result['status'], '货柜 BOX-A:已提柜;货柜 BOX-B:海运途中')
|
||||
self.assertEqual(result['nodes'][0]['place'], '货柜 BOX-A · 码头 A')
|
||||
self.assertEqual(result['estimated_nodes'][0]['place'], '货柜 BOX-B')
|
||||
|
||||
def test_domestic_actual_is_retained_when_ship_has_only_forecasts(self):
|
||||
result = build_tracking([
|
||||
self.fact('DOMESTIC_RECEIVED', '2026-10-28 10:00', shipment_key=''),
|
||||
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00'),
|
||||
])
|
||||
self.assertEqual(result['status'], '已入国内仓')
|
||||
self.assertEqual(len(result['nodes']), 1)
|
||||
|
||||
def test_estimated_only_unknown_codes_and_broken_values_are_safe(self):
|
||||
result = build_tracking([self.fact('PICKED_UP', estimated='2026-11-02 10:00'),
|
||||
{'code': []}, None, self.fact('UNRECOGNIZED', '2026-10-28 10:00'),
|
||||
self.fact('IN_TRANSIT', 'not a time')])
|
||||
self.assertEqual(result['status'], '')
|
||||
self.assertEqual(result['nodes'], [])
|
||||
self.assertEqual(result['estimated_nodes'][0]['text'], '预计提柜')
|
||||
for value in (None, {}, 'broken json', '[null]', []):
|
||||
self.assertIsNone(build_tracking(value))
|
||||
|
||||
def test_tracking_import_preserves_manual_order_fields_and_scopes_by_identity(self):
|
||||
with closing(sqlite3.connect(':memory:')) as connection:
|
||||
orders = [{'order_no': 'ORDER2026001', 'source_order_id': '987654321123456789',
|
||||
'tenant_id': tenant, 'gross_weight_kg': weight}
|
||||
for tenant, weight in [('10', '42'), ('20', '80')]]
|
||||
import_records(connection, orders)
|
||||
record = {**orders[0], 'tracking_json': [self.fact('IN_TRANSIT', '2026-10-28 10:00')]}
|
||||
self.assertEqual(import_tracking_records(connection, [record]),
|
||||
{'imported': 1, 'preserved': 0, 'missing': 0})
|
||||
self.assertEqual(connection.execute('SELECT gross_weight_kg FROM order_test_data '
|
||||
'WHERE tenant_id=\'10\'').fetchone()[0], '42')
|
||||
self.assertEqual(connection.execute('SELECT tracking_json FROM order_test_data '
|
||||
'WHERE tenant_id=\'20\'').fetchone()[0], '[]')
|
||||
self.assertEqual(import_tracking_records(connection, [record])['preserved'], 1)
|
||||
self.assertEqual(import_tracking_records(connection, [record], replace=True)['imported'], 1)
|
||||
with self.assertRaises(ValueError):
|
||||
import_tracking_records(connection, [{**record, 'source_order_id': 'wrong'}], replace=True)
|
||||
self.assertEqual(import_tracking_records(connection, [{**record, 'order_no': 'MISSING2026001'}])['missing'], 1)
|
||||
|
||||
def test_old_table_is_migrated_without_losing_data(self):
|
||||
with closing(sqlite3.connect(':memory:')) as connection:
|
||||
import_records(connection, [{'tenant_id': '10', 'order_no': 'ORDER2026001'}])
|
||||
connection.execute('ALTER TABLE order_test_data DROP COLUMN tracking_json')
|
||||
import_tracking_records(connection, [])
|
||||
self.assertEqual(connection.execute('SELECT order_no,tracking_json FROM order_test_data').fetchone(),
|
||||
('ORDER2026001', '[]'))
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Short recordings are validated and sent only to the separately configured ASR."""
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
|
||||
from logistics_chat import create_app
|
||||
from logistics_chat.speech_service import MAX_AUDIO_BYTES
|
||||
|
||||
|
||||
class SpeechResponse:
|
||||
def __init__(self, payload=None, status=200, raw=None):
|
||||
self.status_code = status
|
||||
self.raw = raw if raw is not None else json.dumps(payload or {'choices': [{'message': {'content': '查询订单状态'}}]}).encode()
|
||||
self.closed = False
|
||||
|
||||
def iter_content(self, size):
|
||||
yield self.raw
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class SpeechTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.root = Path(directory.name)
|
||||
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
|
||||
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups', 'LOG_DIR': self.root/'logs',
|
||||
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
|
||||
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
|
||||
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
|
||||
'duration': duration, 'language':'zh', **extra})
|
||||
self.addCleanup(response.close)
|
||||
self.addCleanup(response.request.environ['wsgi.input'].close)
|
||||
return response
|
||||
|
||||
def test_missing_asr_key_does_not_reuse_chat_key_or_accept_audio(self):
|
||||
self.app.config['ASR_API_KEY'] = ''
|
||||
response = self.client.get('/api/speech/config')
|
||||
self.assertFalse(response.json['enabled'])
|
||||
self.assertIn('no-store', response.headers['Cache-Control'])
|
||||
self.assertNotIn('key', response.get_data(as_text=True).lower())
|
||||
with patch('logistics_chat.speech_service.requests.post') as post:
|
||||
self.assertEqual(self.recording().status_code, 503)
|
||||
post.assert_not_called()
|
||||
|
||||
def test_valid_recording_uses_asr_only_and_never_persists_audio(self):
|
||||
upstream = SpeechResponse()
|
||||
with patch('logistics_chat.speech_service.requests.post', return_value=upstream) as post:
|
||||
response = self.recording()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, {'ok': True, 'text': '查询订单状态'})
|
||||
url = post.call_args.args[0]
|
||||
options = post.call_args.kwargs
|
||||
self.assertEqual(url, self.app.config['ASR_URL'])
|
||||
self.assertEqual(options['headers']['Authorization'], 'Bearer asr-test-key')
|
||||
self.assertEqual(options['json']['model'], 'test-asr')
|
||||
self.assertEqual(options['json']['asr_options']['language'], 'zh')
|
||||
self.assertTrue(options['json']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/webm;base64,'))
|
||||
self.assertFalse(options['allow_redirects'])
|
||||
self.assertTrue(upstream.closed)
|
||||
self.assertEqual(list(self.root.glob('uploads/**/*')), [])
|
||||
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||
|
||||
def test_browser_audio_containers_are_supported_without_trusting_filename(self):
|
||||
samples = [(b'\x00\x00\x00\x18ftypisom' + b'x'*20, 'audio/mp4'),
|
||||
(b'OggS'+b'x'*32, 'audio/ogg'), (b'RIFF'+b'x'*4+b'WAVE'+b'x'*20, 'audio/wav')]
|
||||
for content, mime in samples:
|
||||
with self.subTest(mime=mime), patch('logistics_chat.speech_service.requests.post', return_value=SpeechResponse()):
|
||||
self.assertEqual(self.recording(content, mime).status_code, 200)
|
||||
|
||||
def test_invalid_audio_size_duration_and_container_never_reach_provider(self):
|
||||
with patch('logistics_chat.speech_service.requests.post') as post:
|
||||
self.assertEqual(self.recording(b'html masquerading as audio'*2).status_code, 415)
|
||||
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*MAX_AUDIO_BYTES).status_code, 413)
|
||||
for duration in ('NaN', 'inf', '-1', '0.1', '62', 'broken'):
|
||||
with self.subTest(duration=duration):
|
||||
self.assertEqual(self.recording(duration=duration).status_code, 400)
|
||||
self.assertEqual(self.client.post('/api/speech/transcribe', data={}).status_code, 400)
|
||||
post.assert_not_called()
|
||||
|
||||
def test_request_body_limit_returns_json_instead_of_an_html_error(self):
|
||||
response = self.recording(b'x'*(MAX_AUDIO_BYTES+128*1024))
|
||||
self.assertEqual(response.status_code, 413)
|
||||
self.assertFalse(response.json['ok'])
|
||||
|
||||
def test_upstream_failures_timeout_and_malformed_results_are_recoverable(self):
|
||||
for upstream in (SpeechResponse(status=401, raw=b'secret upstream message'),
|
||||
SpeechResponse(raw=b'invalid'), SpeechResponse(payload={'choices': []}),
|
||||
SpeechResponse(raw=b'x' * 65537)):
|
||||
with self.subTest(raw=upstream.raw[:20]), patch('logistics_chat.speech_service.requests.post', return_value=upstream):
|
||||
response = self.recording()
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertNotIn('secret', response.get_data(as_text=True))
|
||||
self.assertTrue(upstream.closed)
|
||||
with patch('logistics_chat.speech_service.requests.post', side_effect=requests.Timeout):
|
||||
self.assertEqual(self.recording().status_code, 504)
|
||||
|
||||
def test_silence_and_excessively_long_transcripts_are_not_sent_to_chat(self):
|
||||
for text in ('', ' ', '字' * 2001):
|
||||
with self.subTest(length=len(text)), patch('logistics_chat.speech_service.requests.post',
|
||||
return_value=SpeechResponse(payload={'choices': [{'message': {'content': text}}]})):
|
||||
self.assertEqual(self.recording().status_code, 422)
|
||||
|
||||
def test_baidu_converts_browser_audio_and_caches_a_separate_access_token(self):
|
||||
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
|
||||
responses=[SpeechResponse(payload={'access_token':'baidu-test-token','expires_in':3600}),
|
||||
SpeechResponse(payload={'err_no':0,'result':['查询订单。']}),
|
||||
SpeechResponse(payload={'err_no':0,'result':['查询订单。']})]
|
||||
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
||||
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
|
||||
patch('logistics_chat.speech_service.requests.post',side_effect=responses) as post:
|
||||
for _ in range(2):
|
||||
response=self.recording()
|
||||
self.assertEqual(response.status_code,200)
|
||||
self.assertEqual(response.json['text'],'查询订单。')
|
||||
self.assertEqual(post.call_count,3)
|
||||
self.assertEqual(post.call_args_list[0].kwargs['data']['client_id'],'baidu-test-key')
|
||||
for call in post.call_args_list[1:]:
|
||||
self.assertEqual(call.args[0],'https://vop.baidu.com/server_api')
|
||||
self.assertEqual(call.kwargs['json']['format'],'pcm')
|
||||
self.assertEqual(call.kwargs['json']['rate'],16000)
|
||||
self.assertEqual(call.kwargs['json']['channel'],1)
|
||||
self.assertEqual(call.kwargs['json']['token'],'baidu-test-token')
|
||||
self.assertTrue(all(response.closed for response in responses))
|
||||
|
||||
def test_baidu_reports_permission_failure_and_never_returns_upstream_secrets(self):
|
||||
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
|
||||
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
||||
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
|
||||
patch('logistics_chat.speech_service.requests.post',side_effect=[
|
||||
SpeechResponse(payload={'access_token':'token','expires_in':3600}),
|
||||
SpeechResponse(payload={'err_no':3302,'err_msg':'private provider details'})]):
|
||||
response=self.recording()
|
||||
self.assertEqual(response.status_code,503)
|
||||
self.assertNotIn('private',response.get_data(as_text=True))
|
||||
|
||||
def test_decoder_rejects_real_duration_beyond_limit_and_handles_failure_or_timeout(self):
|
||||
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='test-key', BAIDU_ASR_SECRET_KEY='test-secret')
|
||||
cases=[(subprocess.CompletedProcess([],0,stdout=b'\0'*(32000*61),stderr=b''),400),
|
||||
(subprocess.CompletedProcess([],1,stdout=b'',stderr=b'private decoder error'),422)]
|
||||
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
||||
patch('logistics_chat.speech_service.requests.post') as post:
|
||||
for result,status in cases:
|
||||
with patch('logistics_chat.speech_service.subprocess.run',return_value=result):
|
||||
self.assertEqual(self.recording(duration='1').status_code,status)
|
||||
with patch('logistics_chat.speech_service.subprocess.run',side_effect=subprocess.TimeoutExpired('ffmpeg',10)):
|
||||
self.assertEqual(self.recording().status_code,504)
|
||||
post.assert_not_called()
|
||||
|
||||
def test_large_recordings_stay_in_memory_and_the_upload_stream_is_closed(self):
|
||||
from logistics_chat import speech_service
|
||||
original=speech_service.transcribe_recording
|
||||
streams=[]
|
||||
def inspect(upload,*args):
|
||||
self.assertIsInstance(upload.stream,io.BytesIO)
|
||||
streams.append(upload.stream)
|
||||
return original(upload,*args)
|
||||
with patch('logistics_chat.speech_service.transcribe_recording',side_effect=inspect), \
|
||||
patch('logistics_chat.speech_service.requests.post',return_value=SpeechResponse()):
|
||||
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*600000).status_code,200)
|
||||
self.assertTrue(streams[0].closed)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user