From a0819663421a3dd19bed462d95c287ea5a3f6008 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 29 Oct 2026 18:17:24 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E8=AE=A2=E5=8D=95=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=9F=A5=E5=8D=95=E8=BF=BD?= =?UTF-8?q?=E9=97=AE=E4=B8=B2=E5=85=A5=E6=8A=A5=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 2 +- logistics_chat/order_data.py | 30 +++++++++++++++++++++++------- logistics_chat/tracking.py | 2 +- static/css/chat.css | 2 +- static/js/chat.js | 16 +--------------- tests/frontend/chat.test.cjs | 7 ++++--- tests/test_order_data.py | 24 ++++++++++++++++++++++-- 7 files changed, 53 insertions(+), 30 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a17f260..762e851 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index d312255..cb14554 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -20,6 +20,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目 'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级', 'parent':'父订单号', 'updated':'来源更新时间'} IDENTIFIER = re.compile(r'(? 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])} diff --git a/logistics_chat/tracking.py b/logistics_chat/tracking.py index 978aaed..746c932 100644 --- a/logistics_chat/tracking.py +++ b/logistics_chat/tracking.py @@ -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 "(已为该运单查询过轨迹)" diff --git a/static/css/chat.css b/static/css/chat.css index f2e198b..505ef24 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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; } diff --git a/static/js/chat.js b/static/js/chat.js index 45e8aa4..3b34d11 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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); diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index c8670aa..425667e 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -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:''}], 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); }); diff --git a/tests/test_order_data.py b/tests/test_order_data.py index 69629a4..76b4ea1 100644 --- a/tests/test_order_data.py +++ b/tests/test_order_data.py @@ -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))