完善混合提问与报价连续追问

This commit is contained in:
2026-11-06 16:02:46 +08:00
parent 2409bb5c36
commit afe2bd1207
19 changed files with 658 additions and 52 deletions
+242
View File
@@ -0,0 +1,242 @@
"""Mixed-domain answers and displayed quote references preserve scope and facts."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db,order_data
from logistics_chat.quote_data import import_snapshot
from test_quote_timing import timing_snapshot
from test_quotes import frames
class ConversationCompositionTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'3'*64})
self.cid=self.client.post('/api/new_chat').json['id']
with self.app.app_context():
connection=db.connect()
import_snapshot(connection,timing_snapshot(),activate=True)
order_data.import_records(connection,[
{'tenant_id':'a','order_no':'MIXORDER20260001','inbound_code':'MIXIN20260001','status':'PROCESSING',
'exception_reason':'等待资料','source_updated_at':'2026-11-02 10:00'},
{'tenant_id':'a','order_no':'MIXORDER20260002','inbound_code':'MIXIN20260002','status':'PROCESSING',
'source_updated_at':'2026-11-01 10:00'},
{'tenant_id':'b','order_no':'SECRETORDER20260001','exception_reason':'SECRET_REASON'},
])
connection.commit()
connection.close()
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def ask(self,question):
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':self.cid}))
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
self.assertNotIn('SECRET',str(events))
self.assertNotIn('plan_option',str(events))
return events[0]
def test_guest_gets_public_quote_and_login_prompt_without_order_reads(self):
with patch('logistics_chat.order_listing.read_orders') as read:
answer=self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快多少钱')
read.assert_not_called()
self.assertTrue(answer['login_required'])
self.assertIn('1000KG = 10000元',answer['t'])
self.assertNotIn('MIXORDER',answer['t'])
def test_customer_gets_both_answers_and_logout_protects_the_combined_history(self):
self.login()
answer=self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快多少钱')
self.assertIn('共有 2 票订单',answer['t'])
self.assertIn('1000KG = 10000元',answer['t'])
self.assertTrue(self.client.get('/api/get_messages',query_string={'conv_id':self.cid}).json)
self.client.post('/api/customer/logout',json={})
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':self.cid}).json,[])
def test_reverse_domain_order_and_followup_quantity_keep_both_results(self):
self.login()
answer=self.ask('美国1000KG义乌美东海派最快多少钱,顺便我有多少异常订单')
self.assertIn('1 票明确标记异常',answer['t'])
self.assertIn('1000KG = 10000元',answer['t'])
self.assertIn('1100KG = 11000元',self.ask('再加100KG')['t'])
def test_mixed_reply_requires_domain_for_bare_ordinal_and_supports_explicit_ones(self):
self.login()
self.ask('我有多少个订单,同时美国1000KG义乌美东海派最快和最便宜多少钱')
ambiguous=self.ask('第二个呢')['t']
self.assertIn('订单',ambiguous)
self.assertIn('报价方案2',ambiguous)
self.assertEqual(self.ask('第二个订单')['card']['no'],'MIXORDER20260002')
quote=self.ask('第二个方案')['t']
self.assertIn('1000KG = 6000元',quote)
self.assertNotIn('美森正班海派',quote)
def test_second_displayed_plan_remains_selected_through_acknowledgement_and_edits(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
selected=self.ask('第二个方案呢')['t']
self.assertIn('当前按方案2继续核算',selected)
self.assertIn('1000KG = 6000元',selected)
self.ask('就选这个')
self.assertIn('25-30天左右提取',self.ask('多久能提取')['t'])
self.assertIn('1100KG = 6600元',self.ask('再加100KG')['t'])
first=self.ask('第一个方案')['t']
self.assertIn('1100KG = 11000元',first)
self.assertNotIn('普船海派',first)
def test_prompted_plan_number_is_accepted_and_ambiguous_confirmation_is_clarified(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
self.assertIn('方案1、方案2',self.ask('就这个')['t'])
selected=self.ask('我选方案2')['t']
self.assertIn('1000KG = 6000元',selected)
self.assertIn('普船海派',selected)
def test_priority_change_after_a_numbered_selection_can_switch_carriers(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
self.ask('方案1')
text=self.ask('那我想省钱')['t']
self.assertIn('普船海派',text)
self.assertNotIn('美森正班海派',text)
self.assertIn('1000KG = 6000元',text)
def test_english_displayed_options_remain_english(self):
self.ask('1000kg to USA by sea, fastest and cheapest prices please')
selected=self.ask('Option 2')['t']
self.assertIn('Regular sea parcel',selected)
self.assertIn('Continuing with option 2',selected)
self.assertNotRegex(selected,r'[\u4e00-\u9fff]')
def test_english_compact_difference_contains_no_chinese_text(self):
from logistics_chat.quote_data import load_book
from logistics_chat.quote_engine import parse_intent,variants,render_rates
with self.app.app_context():
book=load_book()
intent=parse_intent('1000kg to USA by sea fastest and slowest prices',set())
intent['mode']='difference'
destinations=variants(intent,book)
text=render_rates(intent,book,destinations,[None]*len(destinations))
self.assertNotRegex(text,r'[\u4e00-\u9fff]')
self.assertIn('4000 CNY',text)
def test_out_of_range_plan_does_not_choose_a_route(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
text=self.ask('第9个方案')['t']
self.assertIn('展示了2个方案',text)
self.assertNotIn('KG =',text)
def test_displayed_choice_preserves_carrier_identity_after_prices_change(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
with self.app.app_context():
connection=db.connect()
connection.execute("UPDATE quote_rates SET price='3' WHERE service='美森正班海派'")
connection.commit()
connection.close()
selected=self.ask('第二个方案')['t']
self.assertIn('普船海派',selected)
self.assertIn('1000KG = 6000元',selected)
self.assertNotIn('美森正班海派',selected)
def test_removed_displayed_carrier_requires_a_new_selection_through_followups(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
with self.app.app_context():
connection=db.connect()
connection.execute("DELETE FROM quote_rates WHERE service='普船海派'")
connection.commit()
connection.close()
self.assertIn('暂无可用报价',self.ask('方案2')['t'])
self.assertIn('暂无可用报价',self.ask('那具体多少钱')['t'])
text=self.ask('全部渠道报价')['t']
self.assertIn('美森正班海派',text)
self.assertNotIn('暂无可用报价',text)
def test_new_quote_root_replaces_the_old_plan_catalog(self):
self.ask('美国1000KG义乌美东海派最快和最便宜多少钱')
self.ask('新的一批200KG美国义乌美西海派报价')
selected=self.ask('第二个方案')['t']
self.assertIn('合德海派',selected)
self.assertIn('200KG = 1200元',selected)
self.assertNotIn('1000KG',selected)
def test_legacy_unnumbered_rows_keep_their_positions_after_a_carrier_is_removed(self):
from logistics_chat.quote_plans import displayed_options
from logistics_chat.quote_data import load_book
content='美东:\n- 旧渠道 · 义乌 · 包税:5元/KG\n- 普船海派 · 义乌 · 包税:6元/KG'
with self.app.app_context():
options=displayed_options(content,load_book())
self.assertEqual([option['number'] for option in options],[1,2])
self.assertEqual(options[0]['rows'],[])
self.assertEqual(options[1]['rows'][0]['service'],'普船海派')
def test_removed_warehouse_does_not_reuse_the_previous_destination(self):
from logistics_chat.quote_plans import displayed_options
from logistics_chat.quote_data import load_book
content='美东:\n- 普船海派 · 义乌 · 包税:6元/KG\nABC1 · 东1区:\n- 普船海派 · 义乌 · 包税:6元/KG'
with self.app.app_context():
options=displayed_options(content,load_book())
self.assertEqual(options[1]['warehouse'],'ABC1')
self.assertTrue(options[1]['destination_unavailable'])
def test_different_visitor_cannot_use_private_plan_bindings(self):
self.login()
self.ask('我有多少订单,同时美国1000KG义乌美东海派最快和最便宜多少钱')
other=self.app.test_client()
other.post('/api/visitor/session',json={'key':'4'*64})
with patch('logistics_chat.quote_plans.history_bindings') as read:
response=frames(other.post('/stream_chat',json={'msg':'方案2','conv_id':self.cid}))
read.assert_not_called()
self.assertTrue(any(event.get('e') for event in response))
def test_known_box_count_and_weights_are_not_requested_again(self):
text=self.ask('美国10箱每箱20KG义乌美西普船海派报价')['t']
question=text.splitlines()[-1]
self.assertIn('每箱长宽高',question)
self.assertIn('目的仓码或邮编',question)
self.assertNotIn('箱数',question)
self.assertNotIn('重量',question)
text=self.ask('每箱50x40x30cm')['t']
question=text.splitlines()[-1]
self.assertNotIn('长宽高',question)
self.assertNotIn('重量',question)
def test_all_known_shipment_fields_produce_no_repeated_followup(self):
text=self.ask('美国10箱每箱20KG每箱50x40x30cm,ZZZ1,义乌普船海派报价')['t']
self.assertNotIn('请补充',text)
def test_selected_warehouse_variant_preserves_its_zone_and_assigned_weight(self):
snapshot=timing_snapshot()
snapshot['source_sha256']='2'*64
snapshot['warehouses'].append({**snapshot['warehouses'][0],'code':'ALT1','zone':'西1区','postal':'90001'})
with self.app.app_context():
connection=db.connect()
import_snapshot(connection,snapshot,activate=True)
connection.commit()
connection.close()
self.ask('美国ZZZ1 100KG,ALT1 200KG,义乌海派报价,总体积10CBM')
# ZZZ1 has two zones, with three parcel options in each zone.
selected=self.ask('方案4')['t']
self.assertIn('ZZZ1 · 东2区',selected)
self.assertNotIn('东1区',selected)
self.assertNotIn('ALT1',selected)
self.assertIn('100KG = 1100元',selected)
self.assertNotIn('300KG',selected)
def test_difference_is_short_and_details_expand_without_losing_conditions(self):
self.ask('美国1000KG海派最快和最慢报价')
text=self.ask('快的贵多少')['t']
self.assertLess(len(text),350)
self.assertIn('差额为4000元',text)
self.assertIn('15天、25–30天',text)
self.assertNotIn('1000KG =',text)
details=self.ask('展开报价明细')['t']
self.assertIn('1000KG = 10000元',details)
self.assertIn('1000KG = 6000元',details)