diff --git a/README.md b/README.md index 7aa62b9..1e582b4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ 代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位: -英国报价及其连续追问使用英国费率。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。 +报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。 | 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 | |---|---|---| diff --git a/docs/architecture.md b/docs/architecture.md index 0a6ba00..9cd1efc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,10 +108,11 @@ tests/ # 按业务模块组织的回归 - `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径和重量/日期,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 - `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。 - 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。 -- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表,迟到的刷新不覆盖新结果。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 +- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 - 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 - 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。 -- 计费档位、单位换算和舍入保持原样。`shipping_intent_with_context` 在报价追问中保留重量、尺寸、渠道和区域,遇到新的完整问题停止回溯;英国使用英国渠道和默认比价组合。明确提到美国等未覆盖目的地时不套用欧洲费率,英国按方报价不套用欧洲仓价;指定国家或仓点没有匹配时 `calc_warehouse_card` 返回空。`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,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。 - `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。 - 后台鉴权和会话隔离仍不完整,订单表为内部测试副本,不提供实时物流轨迹。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index c94cbe8..e54488e 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -21,11 +21,34 @@ 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 + 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+\s*[*xX×]\s*\d+\s*[*xX×]\s*\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) - followup = re.match(r'^(?:那|那么|换成|改成|改为|如果|还是|再算|同样)', message.strip()) - if conv_id and (followup or (intent['channel'] and not intent['weight'] and not intent['dims'])): + 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() @@ -36,6 +59,8 @@ def shipping_intent_with_context(message, conv_id): 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: @@ -43,7 +68,7 @@ def shipping_intent_with_context(message, conv_id): if intent['dims'] is None and earlier['dims']: intent['dims'] = earlier['dims'] intent['dim_unit'] = detect_dim_unit(previous) - if not re.match(r'^(?:那|那么|换成|改成|改为|如果|还是|再算|同样)', previous.strip()): + if not is_shipping_followup(previous, earlier): break if intent['channel']: base = intent['channel'].removesuffix('_uk') @@ -66,6 +91,10 @@ def create_chat_stream(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 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']: diff --git a/logistics_chat/pricing.py b/logistics_chat/pricing.py index 65102f0..e5cd6d1 100644 --- a/logistics_chat/pricing.py +++ b/logistics_chat/pricing.py @@ -122,13 +122,18 @@ def parse_dims(t): def shipping_region(text): if re.search(r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|' - r'\b(?-i:US)\b|\b(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)\b|\bto\s+us\b', text, re.I): + r'(? 19 or not conv_id.isascii() or not conv_id.isdecimal()): + return error('会话编号无效,请新建对话后重试。') + try: + conv_id = int(conv_id) + except (ValueError, TypeError): return error('会话编号无效,请新建对话后重试。') - conv_id = int(conv_id) if not 0 < conv_id <= 9223372036854775807: return error('会话编号无效,请新建对话后重试。') else: diff --git a/static/js/chat.js b/static/js/chat.js index 53a44ab..7ed3dfb 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -586,6 +586,7 @@ clearAllBtn.onclick = function(){ openingConv = null; setComposerStatus(''); chatDom.innerHTML = welcomeHTML(); + removeCachedHistory(); loadHistory(); }).catch(function(){ if(version !== conversationVersion) return; @@ -622,6 +623,13 @@ function loadHistory(){ }).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ }); } +function removeCachedHistory(id){ + historyRequestVersion += 1; + historyList = id === undefined ? [] : historyList.filter(item => item.id !== id); + renderSideHistory(); + renderRecentChat(); +} + function renderSideHistory(){ leftHistoryWrap.innerHTML = ""; const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase(); @@ -695,6 +703,7 @@ function renderSideHistory(){ resetConversation(null); chatDom.innerHTML = welcomeHTML(); } + removeCachedHistory(item.id); return loadHistory(); }).catch(function(){ if(version === conversationVersion) setComposerStatus(t('networkFail'), true); diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index c95ac90..befaca1 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -278,6 +278,35 @@ test('a failed history deletion preserves the active conversation for the next m 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([ diff --git a/tests/test_api.py b/tests/test_api.py index a59edce..e47dd33 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -137,10 +137,11 @@ class ApiTests(unittest.TestCase): 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':999999999999999999999999}, + {'msg':'物流咨询','conv_id':'²'}, {'msg':'物流咨询','conv_id':'9'*5000}) with patch('logistics_chat.ai_client.post_completion') as model: - for payload in invalid: - with self.subTest(payload=payload): + 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() @@ -183,6 +184,59 @@ class ApiTests(unittest.TestCase): 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_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方发英国海运多少钱'})) @@ -308,7 +362,7 @@ 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)) diff --git a/tests/test_rules.py b/tests/test_rules.py index 6d6b2c2..f1cf4bc 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -46,6 +46,10 @@ class RuleTests(unittest.TestCase): 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')) def test_warehouse_country_and_surcharge(self): result = pricing.calc_warehouse_card(2, '海运', country='DE')