精简订单展示并修复查单追问串入报价

This commit is contained in:
2026-10-29 18:17:24 +08:00
parent cded4238a9
commit a081966342
7 changed files with 53 additions and 30 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ tests/ # 按业务模块组织的回归
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径和重量/日期,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值。
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
+23 -7
View File
@@ -20,6 +20,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
'parent':'父订单号', 'updated':'来源更新时间'}
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
ORDER_DETAIL = r'状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
@@ -134,8 +135,21 @@ def build_card(row):
summary = f"订单号 {row['order_no']},业务状态:{status}。"
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
return {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
'nodes':nodes, 'summary':summary, 'snapshot_at':row['snapshot_at'],
'notice':row['data_note'] or '', 'source':'editable_test_table'}
'nodes':nodes, 'summary':summary}
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
if not re.search(ORDER_DETAIL, message):
return False
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
return True
question = re.sub(r'[\s,,。.!!??、::]', '', message)
return bool(re.fullmatch(
r'(?:那|那么|还有|请问|请|查一下|看一下)?(?:它的|订单的)?'
r'(?:(?:状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
r'(?:呢|吗|呀|啊)?', question))
def answer_query(message, conv_id):
@@ -143,7 +157,7 @@ def answer_query(message, conv_id):
if len(references) > 1:
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
reference = references[0] if references else None
if not reference and conv_id and re.search(r'这(?:个|票|单)|刚才|状态|重量|几件|体积|目的地|运费|费用', message):
if not reference and conv_id and is_order_followup(message):
from .db import connect
connection = connect()
try:
@@ -156,6 +170,8 @@ def answer_query(message, conv_id):
if earlier_references:
reference = earlier_references[0] if len(earlier_references) == 1 else None
break
if not is_order_followup(content):
break
if role == 'assistant' and content.startswith('__CARD__'):
try:
previous = json.loads(content[8:])
@@ -166,16 +182,16 @@ def answer_query(message, conv_id):
pass
if not reference:
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
return {'t':'请提供订单号、入仓码或 Shipment ID,我会查询本地订单测试表中的记录。'}
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
return None
try:
matches = lookup_order(reference)
except OrderDataUnavailable:
return {'t':'订单测试表暂时无法读取,请稍后重试;当前无法确认该订单状态。'}
return {'t':'订单信息暂时无法读取,请稍后重试。'}
if not matches:
return {'t':f'订单测试表中未找到“{reference}”。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
if not references and re.search(r'运费|费用|多少钱|价格', message):
return {'t':'这份订单测试数据没有实际运费字段,无法据此确认该订单费用,请联系人工客服核实。'}
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
return {'card': build_card(matches[0])}
+1 -1
View File
@@ -14,7 +14,7 @@ def card_to_text(content):
try:
card = json.loads(content[len(CARD_PREFIX):])
if card.get('kind') == 'order':
return '(订单测试数据副本,非实时运输轨迹)' + card.get('summary', '')
return card.get('summary', '')
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
except Exception:
return "(已为该运单查询过轨迹)"
+1 -1
View File
@@ -155,7 +155,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
.order-data-fields dt { color: var(--muted); }
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
.order-snapshot-time { color: var(--muted); font-size: 11px; margin: 10px 0; }
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
.to-bottom.show { display: block; }
+1 -15
View File
@@ -232,11 +232,9 @@ const I18N = {
humanAgent: "人工客服",
trackingNo: "运单号",
orderNo: "订单号",
orderDataNotice: "本地订单测试数据副本。业务状态不代表快递签收,未提供的运输节点不作推断。",
orderHistory: "订单业务状态历史",
noOrderHistory: "未提供订单状态历史",
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。",
orderSnapshot: "数据导入时间",
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
@@ -326,11 +324,9 @@ const I18N = {
humanAgent: "Support agent",
trackingNo: "Tracking number",
orderNo: "Order number",
orderDataNotice: "Editable local test data. Order workflow status does not confirm delivery. Missing transport events are not inferred.",
orderHistory: "Order workflow history",
noOrderHistory: "No order status history provided",
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.",
orderSnapshot: "Snapshot imported",
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
@@ -808,10 +804,6 @@ function fillTrackCard(el, card){
}
if(isOrder){
const notice = document.createElement('p');
notice.className = 'order-data-notice';
notice.textContent = t('orderDataNotice') + (card.notice ? ' ' + card.notice : '');
el.appendChild(notice);
const fields = document.createElement('dl');
fields.className = 'order-data-fields';
(card.fields || []).forEach(function(field){
@@ -823,12 +815,6 @@ function fillTrackCard(el, card){
fields.append(label, value);
});
el.appendChild(fields);
if(card.snapshot_at){
const snapshot = document.createElement('p');
snapshot.className = 'order-snapshot-time';
snapshot.textContent = t('orderSnapshot')+':'+card.snapshot_at;
el.appendChild(snapshot);
}
const title = document.createElement('p');
title.className = 'order-history-title';
title.dataset.i18n = 'orderHistory';
@@ -836,7 +822,7 @@ function fillTrackCard(el, card){
el.appendChild(title);
if(!(card.nodes || []).length){
const empty = document.createElement('p');
empty.className = 'order-snapshot-time';
empty.className = 'order-history-empty';
empty.dataset.i18n = 'noOrderHistory';
empty.textContent = t('noOrderHistory');
el.appendChild(empty);
+4 -3
View File
@@ -310,12 +310,12 @@ test('human poll failures are visible and clear after the connection recovers',
assert.equal(app.element('composer-status').hidden, true);
});
test('order cards show copied facts and render data fields as text', async () => {
test('order cards show facts as text without import metadata, including older saved cards', async () => {
const app = await customerPage({human:false});
const card = {kind:'order', no:'FL202610290001', status:'履约中',
fields:[{key:'weight',value:'30.000 kg'},{key:'warehouse',value:'<img src=x onerror=bad()>'}],
nodes:[{time:'2026-10-29 10:00',text:'已提交',place:''}],
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:''};
summary:'订单 FL202610290001,申报毛重30.000 kg', snapshot_at:'2026-10-29 10:30', notice:'旧数据说明'};
app.hooks['/stream_chat'] = () => {
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
@@ -331,6 +331,7 @@ test('order cards show copied facts and render data fields as text', async () =>
assert.ok(fields);
assert.equal(fields.children[1].textContent, '30.000 kg');
assert.equal(fields.children[3].innerHTML, '');
assert.match(log.querySelector('.order-data-notice').textContent, /测试数据副本/);
assert.equal(log.querySelector('.order-data-notice'), null);
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
assert.equal(app.context.window.__busy, false);
});
+22 -2
View File
@@ -110,7 +110,7 @@ class OrderDataTests(unittest.TestCase):
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
card = self.events('CHILD20260001-01')[0]['card']
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
self.assertIn('未提供', card['notice'])
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
def test_followups_refresh_local_data_and_remain_within_conversation(self):
cid = self.client.post('/api/new_chat').json['id']
@@ -122,18 +122,38 @@ class OrderDataTests(unittest.TestCase):
card = self.events('这票重量是多少?',cid)[0]['card']
self.assertEqual(card['no'], self.record['order_no'])
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
self.assertIn('没有实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
self.events('查询 UNKNOWN20260001',cid)
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
other_cid = self.client.post('/api/new_chat').json['id']
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
def test_new_shipping_questions_do_not_reuse_previous_order(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
with self.subTest(message=message):
self.assertIsNone(order_data.answer_query(message, cid))
db.save_message(cid, 'user', '美国海运费用怎么算?')
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
def test_short_order_followups_read_facts_without_calling_model(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
with patch('logistics_chat.ai_client.post_completion') as model:
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
with self.subTest(message=message):
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
model.assert_not_called()
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))
self.assertIn('30.000', text)
self.assertIn('US', text)
self.assertNotIn('测试', text)
self.assertNotIn('tenant_id', text)
self.assertNotIn('customer_name', json.dumps(card))