Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80895b75f |
@@ -113,7 +113,7 @@ tests/ # 按业务模块组织的回归
|
||||
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
|
||||
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||
- 节点导出:`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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
||||
@@ -124,7 +124,7 @@ tests/ # 按业务模块组织的回归
|
||||
- 手机语音由 `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 表,不预置账号或演示数据。
|
||||
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
|
||||
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录和订单关联尚未实现,游客身份不能升级为订单权限。
|
||||
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录独立校验,游客身份不能升级为订单权限。
|
||||
|
||||
## 验证与维护
|
||||
|
||||
|
||||
+2
-2
@@ -34,11 +34,11 @@
|
||||
|
||||
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
|
||||
|
||||
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。
|
||||
人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
|
||||
|
||||
### 电话、联系方式、怎么联系、联系你们、微信
|
||||
|
||||
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
||||
可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -265,11 +265,11 @@ knowledge_list = [
|
||||
},
|
||||
{
|
||||
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
|
||||
"text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。"
|
||||
"text":"人工客服在线时间:每天北京时间 8:00—22:00,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。"
|
||||
},
|
||||
{
|
||||
"keys":["电话","联系方式","怎么联系","联系你们","微信"],
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||
},
|
||||
{
|
||||
"keys":["退件","退回","退货","退运","怎么退运"],
|
||||
|
||||
@@ -212,8 +212,31 @@ def build_card(row):
|
||||
return card
|
||||
|
||||
|
||||
def is_lookup_help(message):
|
||||
return bool(re.search(r'是什么意思|是什么|指什么|怎么(?:查|操作|使用)|如何(?:查|操作|使用)|怎样(?:查|操作|使用)|'
|
||||
r'\b(?:what\s+(?:is|are)|how\s+(?:do|can|to))\b', message, re.I))
|
||||
|
||||
|
||||
def is_tracking_request(message):
|
||||
"""Bounded tracking commands also work without an identifier or prior context."""
|
||||
question = re.sub(r'[\s,,。.!!??、::]', '', message)
|
||||
command = (r'(?:请问|请|麻烦)?(?:帮我)?(?:查(?:询|一下|下)?(?:物流(?:轨迹|进度|状态)?|轨迹|进度|货)|'
|
||||
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
|
||||
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
|
||||
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
|
||||
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
|
||||
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
|
||||
r'(?:parcels?|packages?|shipments?|orders?)')
|
||||
return bool(re.fullmatch(command+'|'+personal, question) or
|
||||
re.fullmatch(english, message.strip(' \t\r\n.!?'), re.I))
|
||||
|
||||
|
||||
def is_order_followup(message):
|
||||
"""Reuse an order only for an explicit reference or a short detail question."""
|
||||
if is_lookup_help(message):
|
||||
return False
|
||||
if is_tracking_request(message):
|
||||
return True
|
||||
if not re.search(ORDER_DETAIL, message):
|
||||
return False
|
||||
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
|
||||
@@ -263,11 +286,11 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I):
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I)):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||
return None
|
||||
if not tenants:
|
||||
|
||||
+39
-4
@@ -41,6 +41,7 @@ let historyRequestVersion = 0;
|
||||
let followLatest = true;
|
||||
let voiceInput = null;
|
||||
let customerLogin = null;
|
||||
let customerRecovery = null;
|
||||
let creatingConv = null;
|
||||
let sendPreparing = false;
|
||||
let conversationVersion = 0;
|
||||
@@ -877,7 +878,7 @@ function resetConversation(id){
|
||||
|
||||
// 打开历史会话:还原整段对话,而不是把标题重发一遍
|
||||
let openingConv = null;
|
||||
function openConversation(id){
|
||||
function openConversation(id, afterLoad){
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv === id) return; // 防连点
|
||||
const version = resetConversation(id);
|
||||
@@ -895,6 +896,7 @@ function openConversation(id){
|
||||
chatDom.innerHTML = "";
|
||||
if(!msgs.length){
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
if(afterLoad) afterLoad(false);
|
||||
return;
|
||||
}
|
||||
let previousQuestion = '';
|
||||
@@ -917,10 +919,12 @@ function openConversation(id){
|
||||
});
|
||||
// 等渲染完再滚到底部
|
||||
requestAnimationFrame(function(){ scrollChatToEnd(true); });
|
||||
if(afterLoad) afterLoad(true);
|
||||
}).catch(function(){
|
||||
if(version !== conversationVersion) return;
|
||||
openingConv = 'failed';
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
if(afterLoad) afterLoad(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1432,10 +1436,35 @@ function attachCustomerLogin(bubble, question){
|
||||
button.type = 'button'; button.className = 'customer-login-cta';
|
||||
button.textContent = t('customerQuery');
|
||||
const version = conversationVersion;
|
||||
const convId = currentConvId;
|
||||
function sameContext(user){
|
||||
return version === conversationVersion || (customerRecovery &&
|
||||
customerRecovery.from === version && customerRecovery.to === conversationVersion &&
|
||||
(!user || user.id === customerRecovery.customerId));
|
||||
}
|
||||
button.onclick = function(){
|
||||
customerLogin.require(async function(){
|
||||
if(version === conversationVersion && question) await retryAnswer(bubble, question);
|
||||
});
|
||||
if(version !== conversationVersion) return;
|
||||
customerLogin.require(async function(user){
|
||||
if(!sameContext(user) || !question) return;
|
||||
if(version === conversationVersion){ await retryAnswer(bubble, question); return; }
|
||||
// Expiry cleared the private screen. Re-open it through the owner-checked
|
||||
// endpoint before continuing a short followup under the same customer.
|
||||
const newerDraft = inputDom.value;
|
||||
customerRecovery = null;
|
||||
let restoredDraft;
|
||||
openConversation(convId, function(loaded){
|
||||
const draft = inputDom.value !== restoredDraft ? inputDom.value : newerDraft;
|
||||
if(!loaded){
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
}
|
||||
inputDom.value=draft; autoGrow();
|
||||
if(!loaded) return;
|
||||
addMsg(question, true);
|
||||
retryAnswer(bubble, question);
|
||||
});
|
||||
restoredDraft = inputDom.value;
|
||||
}, sameContext);
|
||||
};
|
||||
bubble.appendChild(button);
|
||||
}
|
||||
@@ -1991,7 +2020,13 @@ customerLogin = CustomerLogin.create({fetch:fetch, text:t,
|
||||
onChange:function(user,previous){
|
||||
if(previous === undefined) return;
|
||||
if(previous && (!user || user.id !== previous.id)){
|
||||
const from = conversationVersion;
|
||||
resetConversation(null); chatDom.innerHTML=welcomeHTML();
|
||||
customerRecovery = !user ? {from:from,to:conversationVersion,customerId:previous.id} : null;
|
||||
// Customer titles must disappear even if the next history request fails.
|
||||
historyRequestVersion += 1;
|
||||
historyList=[];
|
||||
renderSideHistory(); renderRecentChat();
|
||||
}
|
||||
loadHistory();
|
||||
},
|
||||
|
||||
@@ -111,10 +111,11 @@ var CustomerLogin = {
|
||||
window.addEventListener('focus',refresh);
|
||||
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
|
||||
render();
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume){
|
||||
return {open:open,refresh:refresh,render:render,require:async function(resume, canContinue){
|
||||
if(busy) return;
|
||||
await refresh();
|
||||
if(user){ await resume(); }else open(resume);
|
||||
if(canContinue && !canContinue(user)) return;
|
||||
if(user){ await resume(user); }else open(function(){ return resume(user); });
|
||||
},viewport:function(height,top){
|
||||
overlay.style.height=height || ''; overlay.style.top=top || '';
|
||||
overlay.style.bottom=height ? 'auto' : '';
|
||||
|
||||
@@ -1045,6 +1045,52 @@ test('invalid customer credentials leave the login form open without sending ano
|
||||
assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,'');
|
||||
});
|
||||
|
||||
test('reauthentication resumes an expired order followup only for the same customer and conversation', async () => {
|
||||
for(const scenario of [{customer:1},{customer:2},{customer:1,denied:true},
|
||||
{customer:1,edit:true},{customer:1,switched:true}]){
|
||||
const nextCustomer=scenario.customer;
|
||||
const app=await customerPage({human:false});let authenticated=false, customer=1;
|
||||
let releaseHistory;
|
||||
const user=()=>({id:customer,name:customer===1 ? 'admin' : 'other'});
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? user() : null});
|
||||
app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:user()});};
|
||||
app.hooks['/api/get_history']=()=>response(authenticated ? [{id:1,title:'订单',ctime:'now'}] : []);
|
||||
const history=scenario.denied ? [] : [{role:'user',content:'INBOUND20260001'},
|
||||
{role:'assistant',content:'已查询的订单轨迹'}];
|
||||
app.hooks.messages=()=>scenario.edit || scenario.switched
|
||||
? new Promise(resolve=>{releaseHistory=()=>resolve(response(history));}) : response(history);
|
||||
app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'订单轨迹结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]);
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});
|
||||
await app.context.window.fillInput('INBOUND20260001');await flush();
|
||||
authenticated=false;
|
||||
await app.context.window.fillInput('现在到哪了?');await flush();
|
||||
await app.click(app.element('chatDom').querySelector('.customer-login-cta'));
|
||||
assert.equal(app.element('customer-login').hidden,false);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/订单轨迹结果/);
|
||||
app.element('input-text').value='下一条未发送';app.element('input-text').listeners.input();
|
||||
customer=nextCustomer;
|
||||
app.element('customer-username').value=customer===1 ? 'admin' : 'other';
|
||||
app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();await flush();
|
||||
if(releaseHistory){
|
||||
if(scenario.switched) await app.click(app.element('.new-chat-btn'));
|
||||
app.element('input-text').value='恢复期间的新输入';app.element('input-text').listeners.input();
|
||||
releaseHistory();await flush();await flush();
|
||||
}
|
||||
const resumed=nextCustomer===1 && !scenario.denied && !scenario.switched;
|
||||
const sends=app.calls.filter(call=>call.url==='/stream_chat');
|
||||
assert.equal(sends.length,resumed ? 3 : 2);
|
||||
if(resumed){
|
||||
assert.equal(sends[2].body.conv_id,1);assert.equal(sends[2].body.msg,'现在到哪了?');
|
||||
assert.match(app.element('chatDom').textContent,/订单轨迹结果/);
|
||||
assert.match(app.element('chatDom').textContent,/现在到哪了?/);
|
||||
}
|
||||
assert.equal(app.element('input-text').value,releaseHistory ? '恢复期间的新输入' : '下一条未发送');
|
||||
}
|
||||
});
|
||||
|
||||
test('a late login cannot replay an order into a newer conversation', async () => {
|
||||
const app=await customerPage({human:false});let release;
|
||||
app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null});
|
||||
@@ -1076,6 +1122,20 @@ test('signing out clears visible order conversation and returns to guest mode',
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1);
|
||||
});
|
||||
|
||||
test('signing out removes customer history titles even if refreshing guest history fails', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'INBOUND20260001 客户订单',ctime:'now'}]});
|
||||
app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}});
|
||||
app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null});
|
||||
await app.click(app.element('customer-account'));
|
||||
app.element('customer-username').value='admin';app.element('customer-password').value='123456';
|
||||
await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush();
|
||||
assert.match(app.element('historyWrap').textContent,/INBOUND20260001/);
|
||||
app.hooks['/api/get_history']=()=>response({ok:false},503);
|
||||
await app.click(app.element('customer-account'));
|
||||
assert.doesNotMatch(app.element('historyWrap').textContent,/INBOUND20260001/);
|
||||
assert.equal(app.element('customer-account').textContent,'登录');
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
|
||||
@@ -49,6 +49,20 @@ class CustomerTests(unittest.TestCase):
|
||||
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
|
||||
self.assertNotIn('99 kg',forged.get_data(as_text=True))
|
||||
|
||||
def test_common_tracking_requests_require_login_without_calling_the_model(self):
|
||||
questions = ('查物流', '帮我查一下物流', '物流查询', '查货', '帮我查下轨迹',
|
||||
'我的货到哪了', '我的包裹到哪里了?', '这票货什么时候到?',
|
||||
'Where is my parcel?', 'Track my package')
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for question in questions:
|
||||
with self.subTest(question=question):
|
||||
self.assertTrue(self.events(question)[0].get('login_required'))
|
||||
self.login()
|
||||
for question in questions:
|
||||
with self.subTest(authenticated_question=question):
|
||||
self.assertIn('请提供入仓码', self.events(question)[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
|
||||
secure=self.app.test_client()
|
||||
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
|
||||
|
||||
@@ -203,6 +203,23 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_tracking_aliases_reuse_only_the_current_orders_context(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
self.events(self.record['inbound_code'], cid)
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
for question in ('查物流', '帮我查一下物流', '物流查询', '帮我查下轨迹', '查一下进度',
|
||||
'我的货到哪了', '这票货什么时候到?', 'Where is my parcel?'):
|
||||
with self.subTest(question=question):
|
||||
self.assertEqual(self.events(question, cid)[0]['card']['no'], self.record['order_no'])
|
||||
model.assert_not_called()
|
||||
db.save_message(cid, 'user', '美国海运费用怎么算?')
|
||||
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
|
||||
self.assertIn('请提供入仓码', self.events('查物流', cid)[0]['t'])
|
||||
for question in ('物流轨迹是什么意思?', '物流查询怎么操作?', '怎么查物流?',
|
||||
'海运一般多久到?', '包裹丢了怎么办?', 'What is parcel tracking?'):
|
||||
with self.subTest(general_question=question):
|
||||
self.assertIsNone(order_data.answer_query(question, None))
|
||||
|
||||
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))
|
||||
|
||||
@@ -18,6 +18,14 @@ class RuleTests(unittest.TestCase):
|
||||
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
|
||||
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
||||
|
||||
def test_support_knowledge_matches_the_daily_china_time_service_window(self):
|
||||
for question in ('人工客服工作时间', '周末人工客服上班吗', '怎么联系你们'):
|
||||
with self.subTest(question=question):
|
||||
answer = knowledge.search_knowledge(question)
|
||||
self.assertIn('每天北京时间 8:00—22:00', answer)
|
||||
self.assertNotIn('9:00', answer)
|
||||
self.assertNotIn('不上班', answer)
|
||||
|
||||
def test_sea_tier_boundary_and_minimum(self):
|
||||
self.assertEqual(pricing.calc_freight(100, 'sea')['price'], 12.3)
|
||||
self.assertEqual(pricing.calc_freight(101, 'sea')['price'], 10.3)
|
||||
|
||||
Reference in New Issue
Block a user