修复目的地识别、报价短追问及删除后历史同步

This commit is contained in:
2026-10-29 19:04:05 +08:00
parent f4e87b0c02
commit 8444ded971
9 changed files with 151 additions and 15 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
英国报价及其连续追问使用英国费率。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。明确提到美国等当前未覆盖的目的地时交由人工确认;英国按方报价也不会套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|---|---|---|
+3 -2
View File
@@ -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 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
+32 -3
View File
@@ -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']:
+8 -3
View File
@@ -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'(?<![A-Za-z])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])', text, re.I):
return 'unsupported'
if re.search(r'英国|伦敦|英格兰|\b(?:UK|United Kingdom|Britain)\b', text, re.I):
if re.search(r'英国|伦敦|英格兰|(?<![A-Za-z])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?<![A-Za-z])(?:United Kingdom|Britain)(?![A-Za-z])', text, re.I):
return 'uk'
if re.search(r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
r'\b(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)\b', text, re.I):
r'(?<![A-Za-z])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
r'(?<![A-Za-z])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))', text, re.I):
return 'eu'
if re.search(r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b', text, re.I):
return 'unsupported'
return None
+7 -2
View File
@@ -26,9 +26,14 @@ def stream_chat():
if isinstance(conv_id, (bool, float)):
return error('会话编号无效,请新建对话后重试。')
if conv_id not in (None, '', 0):
if not isinstance(conv_id, (str, int)) or not str(conv_id).isdigit():
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('会话编号无效,请新建对话后重试。')
conv_id = int(conv_id)
if not 0 < conv_id <= 9223372036854775807:
return error('会话编号无效,请新建对话后重试。')
else:
+9
View File
@@ -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);
+29
View File
@@ -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([
+58 -4
View File
@@ -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))
+4
View File
@@ -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')