Compare commits

...
10 changed files with 179 additions and 14 deletions
+2 -2
View File
@@ -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
View File
@@ -34,11 +34,11 @@
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。
人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
### 电话、联系方式、怎么联系、联系你们、微信
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。
可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
---
+2 -2
View File
@@ -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":["退件","退回","退货","退运","怎么退运"],
+31 -2
View File
@@ -212,8 +212,37 @@ def build_card(row):
return card
def is_lookup_help(message):
if re.search(r'是什么意思|指什么|怎么(?:查|操作|使用)|如何(?:查|操作|使用)|怎样(?:查|操作|使用)|'
r'\bhow\s+(?:do|can|to)\b', message, re.I):
return True
if re.fullmatch(r'\s*(?:请问)?(?:物流轨迹|物流状态|订单状态|入仓码|入仓号)是什么[。?!?!\s]*', message):
return True
return bool(re.search(r'\bwhat\s+(?:is|are)\b', message, re.I) and
not re.search(r'\b(?:my|this|that|our)\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?)(?:\s+(?:status|progress))?|'
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
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 +292,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
View File
@@ -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();
},
+3 -2
View File
@@ -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' : '';
+60
View File
@@ -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;
+14
View File
@@ -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', 'What is my shipment status?')
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')
+18
View File
@@ -203,6 +203,24 @@ 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?', 'What is my shipment status?'):
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))
+8
View File
@@ -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)