From a80895b75f64b0b02f16d93127b159748e33b48c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 4 Nov 2026 18:21:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9F=A5=E8=B4=A7=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E4=B8=8E=E7=99=BB=E5=BD=95=E8=BF=87=E6=9C=9F=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E4=BC=9A=E8=AF=9D=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 4 +-- docs/knowledge.md | 4 +-- logistics_chat/knowledge.py | 4 +-- logistics_chat/order_data.py | 27 ++++++++++++++-- static/js/chat.js | 43 +++++++++++++++++++++++--- static/js/customer.js | 5 +-- tests/frontend/chat.test.cjs | 60 ++++++++++++++++++++++++++++++++++++ tests/test_customer.py | 14 +++++++++ tests/test_order_data.py | 17 ++++++++++ tests/test_rules.py | 8 +++++ 10 files changed, 172 insertions(+), 14 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ee425c2..b5ed766 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录独立校验,游客身份不能升级为订单权限。 ## 验证与维护 diff --git a/docs/knowledge.md b/docs/knowledge.md index 8a09efc..5de2cb9 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -34,11 +34,11 @@ ### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息 -人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。 +人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。 ### 电话、联系方式、怎么联系、联系你们、微信 -可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。 +可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。 --- diff --git a/logistics_chat/knowledge.py b/logistics_chat/knowledge.py index ec1cfea..9ddc73d 100644 --- a/logistics_chat/knowledge.py +++ b/logistics_chat/knowledge.py @@ -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":["退件","退回","退货","退运","怎么退运"], diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index b48b74b..88fa010 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -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: diff --git a/static/js/chat.js b/static/js/chat.js index 60ba6b3..c692103 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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(); }, diff --git a/static/js/customer.js b/static/js/customer.js index 1046a30..934f907 100644 --- a/static/js/customer.js +++ b/static/js/customer.js @@ -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' : ''; diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 00145e8..14f9a71 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -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; diff --git a/tests/test_customer.py b/tests/test_customer.py index 1e0610c..80b01d8 100644 --- a/tests/test_customer.py +++ b/tests/test_customer.py @@ -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') diff --git a/tests/test_order_data.py b/tests/test_order_data.py index 6cffbe3..a162fab 100644 --- a/tests/test_order_data.py +++ b/tests/test_order_data.py @@ -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)) diff --git a/tests/test_rules.py b/tests/test_rules.py index 21c6aa3..7c47e13 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -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)