287 lines
15 KiB
Python
287 lines
15 KiB
Python
"""Structured quotes preserve exact quantities and transaction-owned turn state."""
|
|
|
|
from hashlib import sha256
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from flask import g
|
|
|
|
from logistics_chat import create_app, db
|
|
from logistics_chat.conversation_state import commit_state, init_state_schema, load_state
|
|
from logistics_chat.quote_data import import_snapshot, load_book
|
|
from logistics_chat.quote_service import answer_quote
|
|
from logistics_chat.quote_tool import QuoteTurn
|
|
from test_quote_timing import timing_snapshot
|
|
|
|
|
|
class QuoteToolTests(unittest.TestCase):
|
|
def setUp(self):
|
|
directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(directory.cleanup)
|
|
root = Path(directory.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': '', 'CHAT_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
|
client = self.app.test_client()
|
|
client.post('/api/visitor/session', json={'key': '7' * 64})
|
|
self.cid = client.post('/api/new_chat').json['id']
|
|
self.context = self.app.test_request_context()
|
|
self.context.push()
|
|
self.addCleanup(self.context.pop)
|
|
g.visitor_id = sha256(('7' * 64).encode()).hexdigest()
|
|
connection = db.connect()
|
|
init_state_schema(connection)
|
|
import_snapshot(connection, timing_snapshot(), activate=True)
|
|
connection.commit()
|
|
connection.close()
|
|
self.book = load_book()
|
|
|
|
def arguments(self, **extra):
|
|
return {'question': '核对报价', 'request': 'quote', 'selection': 'current',
|
|
'services': [], 'policy_topic': 'auto', **extra}
|
|
|
|
def start(self, **shipment):
|
|
turn = QuoteTurn(self.cid, '一票新货', self.book)
|
|
answer = turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000',
|
|
'zone': '美西', 'origin': '义乌', **shipment}))
|
|
self.assertEqual(answer['status'], 'ok', answer)
|
|
self.persist(turn)
|
|
return answer
|
|
|
|
def persist(self, turn, question='已确认货物条件'):
|
|
connection = db.connect()
|
|
try:
|
|
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(?,'user',?,'2026-11-06 12:00:00')", (self.cid, question))
|
|
self.assertTrue(commit_state(connection, self.cid, turn.candidate, turn.expected_version))
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
def test_same_turn_addition_applies_once_across_comparisons(self):
|
|
self.start()
|
|
turn = QuoteTurn(self.cid, '再加100KG,最快最慢都查', self.book)
|
|
change = [{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}]
|
|
for selection in ('fastest', 'slowest', 'fastest'):
|
|
answer = turn.run(self.arguments(changes=change, selection=selection))
|
|
self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100')
|
|
self.assertIn('1100KG', answer['t'])
|
|
self.persist(turn, '再加100KG')
|
|
later = QuoteTurn(self.cid, '再加100KG', self.book)
|
|
answer = later.run(self.arguments(changes=change))
|
|
self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1200')
|
|
|
|
def test_conflicting_same_turn_edits_do_not_commit_partial_change(self):
|
|
self.start()
|
|
turn = QuoteTurn(self.cid, '修改重量', self.book)
|
|
for delta in ('100', '200'):
|
|
answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': delta}]))
|
|
self.assertEqual(answer['status'], 'needs_clarification')
|
|
self.assertIsNone(turn.candidate)
|
|
self.assertEqual(load_state(self.cid)['intent']['weight'], '1000')
|
|
|
|
def test_add_without_base_and_bad_dimensions_are_clarifications(self):
|
|
turn = QuoteTurn(self.cid, '加100', self.book)
|
|
answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}]))
|
|
self.assertEqual(answer['status'], 'needs_clarification')
|
|
self.assertIsNone(turn.candidate)
|
|
for dims in ([20, 0, 30], [20, 30], ['NaN', 20, 30]):
|
|
answer = turn.run(self.arguments(shipment={'country': 'US', 'dimensions_cm': dims}))
|
|
self.assertEqual(answer['status'], 'needs_clarification')
|
|
|
|
def test_structured_dimensions_use_decimal_without_reparsing_question(self):
|
|
turn = QuoteTurn(self.cid, '箱规已经提供', self.book)
|
|
with patch('logistics_chat.quote_service.intent_with_context', side_effect=AssertionError('must not parse')):
|
|
answer = turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '10.5',
|
|
'boxes': 1, 'dimensions_cm': ['60', '40', '30'], 'zone': '美西', 'origin': '义乌'}))
|
|
self.assertEqual(answer['status'], 'ok', answer)
|
|
self.assertIn('12KG', answer['t'])
|
|
self.assertEqual(Decimal(answer['data']['options'][0]['quantity']), Decimal('12'))
|
|
|
|
def test_carton_validation_and_weight_edit_invalidate_derived_fields(self):
|
|
turn = QuoteTurn(self.cid, '两箱', self.book)
|
|
invalid = {'country': 'US', 'total_weight_kg': '50', 'boxes': 2,
|
|
'packages': [{'count': 2, 'weight_kg': '20'}]}
|
|
self.assertEqual(turn.run(self.arguments(shipment=invalid))['status'], 'needs_clarification')
|
|
invalid['total_weight_kg'] = '40'
|
|
invalid.update(material='metal', metal_scope='partial', metal_chargeable_weight_kg='10')
|
|
self.assertEqual(turn.run(self.arguments(shipment=invalid))['status'], 'ok')
|
|
self.persist(turn)
|
|
update = QuoteTurn(self.cid, '总重改成100', self.book)
|
|
answer = update.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'set', 'value': '100'}]))
|
|
self.assertNotIn('packages', answer['data']['confirmed_inputs'])
|
|
self.assertNotIn('metal_chargeable_weight_kg', answer['data']['confirmed_inputs'])
|
|
|
|
def test_country_switch_clears_old_quantities_and_destination(self):
|
|
self.start(warehouse='ZZZ1')
|
|
turn = QuoteTurn(self.cid, '这票改发英国', self.book)
|
|
answer = turn.run(self.arguments(shipment={'country': 'GB'}))
|
|
self.assertEqual(answer['status'], 'unsupported')
|
|
self.assertEqual(answer['data']['confirmed_inputs'], {'country': 'GB'})
|
|
self.assertEqual(turn.candidate['options'], [])
|
|
|
|
def test_fixed_book_survives_active_version_switch(self):
|
|
turn = QuoteTurn(self.cid, '1000KG美国', self.book)
|
|
newer = timing_snapshot()
|
|
newer['source_sha256'] = '2' * 64
|
|
for rate in newer['rates']:
|
|
rate['price'] = '99'
|
|
connection = db.connect()
|
|
import_snapshot(connection, newer, activate=True)
|
|
connection.commit()
|
|
connection.close()
|
|
with patch('logistics_chat.quote_service.load_book', side_effect=AssertionError('must pin book')):
|
|
answer = turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000', 'zone': '美西', 'origin': '义乌'}))
|
|
self.assertEqual(answer['data_version'], self.book['id'])
|
|
self.assertIn('8000元', answer['t'])
|
|
self.assertNotIn('99000元', answer['t'])
|
|
|
|
def test_stale_deleted_and_unowned_state_cannot_be_committed(self):
|
|
first, second = (QuoteTurn(self.cid, '1000KG', self.book) for _ in range(2))
|
|
for turn in (first, second):
|
|
turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000'}))
|
|
self.persist(first)
|
|
connection = db.connect()
|
|
self.assertFalse(commit_state(connection, self.cid, second.candidate, second.expected_version))
|
|
connection.close()
|
|
g.visitor_id = 'other'
|
|
forbidden = QuoteTurn(self.cid, '查报价', self.book)
|
|
self.assertEqual(forbidden.run(self.arguments())['status'], 'error')
|
|
self.assertIsNone(load_state(self.cid))
|
|
g.visitor_id = sha256(('7' * 64).encode()).hexdigest()
|
|
latest = QuoteTurn(self.cid, '1000KG', self.book)
|
|
latest.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000'}))
|
|
connection = db.connect()
|
|
connection.execute('DELETE FROM chat_history WHERE id=?', (self.cid,))
|
|
self.assertFalse(commit_state(connection, self.cid, latest.candidate, latest.expected_version))
|
|
connection.commit()
|
|
connection.close()
|
|
|
|
def test_state_write_rolls_back_with_the_reply_transaction(self):
|
|
turn = QuoteTurn(self.cid, '1000KG', self.book)
|
|
turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000'}))
|
|
connection = db.connect()
|
|
connection.execute('BEGIN IMMEDIATE')
|
|
self.assertTrue(commit_state(connection, self.cid, turn.candidate, turn.expected_version))
|
|
connection.rollback()
|
|
connection.close()
|
|
self.assertEqual(load_state(self.cid)['version'], 0)
|
|
|
|
def test_legacy_fallback_updates_saved_conditions_without_double_add(self):
|
|
self.start()
|
|
answer = answer_quote('再加100KG', self.cid)
|
|
self.assertIn('1100KG', answer['t'])
|
|
self.assertEqual(answer['_quote_state_candidate']['intent']['weight'], '1100')
|
|
connection = db.connect()
|
|
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(?,'user',?,'2026-11-06 12:01:00')", (self.cid, '再加100KG'))
|
|
self.assertTrue(commit_state(connection, self.cid, answer['_quote_state_candidate'], answer['_quote_state_expected_version']))
|
|
connection.commit()
|
|
connection.close()
|
|
turn = QuoteTurn(self.cid, '最快呢', self.book)
|
|
answer = turn.run(self.arguments(shipment={}, selection='fastest'))
|
|
self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100')
|
|
self.assertIn('1100KG', answer['t'])
|
|
|
|
def test_old_numbered_plan_is_not_guessed_from_reply_text(self):
|
|
self.start()
|
|
answer = answer_quote('方案2多少钱', self.cid)
|
|
self.assertIn('不能根据旧回复猜测方案编号', answer['t'])
|
|
|
|
def test_option_reference_rejects_stale_price_version(self):
|
|
initial = self.start()
|
|
option = initial['data']['options'][0]['option_id']
|
|
newer = {**self.book, 'id': self.book['id'] + 100}
|
|
turn = QuoteTurn(self.cid, '按这个方案', newer)
|
|
answer = turn.run(self.arguments(option_id=option))
|
|
self.assertEqual(answer['status'], 'needs_clarification')
|
|
self.assertIn('已失效', answer['t'])
|
|
|
|
def test_changes_only_can_migrate_legacy_history_once(self):
|
|
db.save_message(self.cid, 'user', '1000KG到美国,美西,义乌交仓')
|
|
turn = QuoteTurn(self.cid, '再加100KG', self.book)
|
|
answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}]))
|
|
self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100')
|
|
|
|
def test_later_dimensions_in_same_turn_invalidate_earlier_option_facts(self):
|
|
self.start(total_weight_kg='10')
|
|
turn = QuoteTurn(self.cid, '补充箱规', self.book)
|
|
first = turn.run(self.arguments(shipment={}, selection='fastest'))
|
|
old_ids = {item['option_id'] for item in first['data']['options']}
|
|
answer = turn.run(self.arguments(shipment={'boxes': 1, 'total_weight_kg': '10', 'dimensions_cm': [100, 100, 100]}))
|
|
self.assertEqual(answer['status'], 'ok')
|
|
self.assertTrue(old_ids.isdisjoint({item['option_id'] for item in turn.candidate['options']}))
|
|
|
|
def test_duplicate_change_in_one_call_is_not_applied_twice(self):
|
|
self.start()
|
|
turn = QuoteTurn(self.cid, '加100KG', self.book)
|
|
change = {'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}
|
|
answer = turn.run(self.arguments(changes=[change, change]))
|
|
self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100')
|
|
|
|
def test_other_customer_or_deleted_conversation_cannot_reuse_turn_snapshot(self):
|
|
self.start()
|
|
turn = QuoteTurn(self.cid, '最快呢', self.book)
|
|
g.visitor_id = 'other'
|
|
answer = turn.run(self.arguments(shipment={}))
|
|
self.assertEqual(answer['status'], 'error')
|
|
self.assertEqual(answer['data'], {})
|
|
|
|
def test_all_channels_does_not_inherit_a_previously_selected_service(self):
|
|
self.start()
|
|
turn = QuoteTurn(self.cid, '查普船', self.book)
|
|
answer = turn.run(self.arguments(shipment={}, services=['普船']))
|
|
self.assertNotIn('美森正班海派', answer['t'])
|
|
self.persist(turn)
|
|
later = QuoteTurn(self.cid, '全部渠道', self.book)
|
|
answer = later.run(self.arguments(shipment={}, selection='all'))
|
|
self.assertIn('美森正班海派', answer['t'])
|
|
|
|
def test_new_shipment_is_reset_once_and_keeps_both_comparison_results(self):
|
|
self.start(total_weight_kg='2000')
|
|
turn = QuoteTurn(self.cid, '新一票100KG,最快最慢', self.book)
|
|
payload = {'country': 'US', 'total_weight_kg': '100', 'zone': '美西', 'origin': '义乌'}
|
|
fast = turn.run(self.arguments(shipment=payload, new_shipment=True, selection='fastest'))
|
|
slow = turn.run(self.arguments(shipment=payload, new_shipment=True, selection='slowest'))
|
|
expected = {item['option_id'] for answer in (fast, slow) for item in answer['data']['options']}
|
|
self.assertEqual({item['option_id'] for item in turn.candidate['options']}, expected)
|
|
self.assertEqual(turn.candidate['intent']['weight'], '100')
|
|
|
|
def test_policy_followup_keeps_existing_option_references(self):
|
|
initial = self.start()
|
|
turn = QuoteTurn(self.cid, '怎么算计费重', self.book)
|
|
turn.run(self.arguments(shipment={}, request='policy', policy_topic='weight_basis'))
|
|
self.assertEqual(turn.candidate['options'], initial['data']['options'])
|
|
|
|
def test_legacy_tool_question_does_not_discard_missing_unit_error(self):
|
|
self.start()
|
|
turn = QuoteTurn(self.cid, '重量改成100', self.book)
|
|
answer = turn.run(self.arguments(question='重量改成100'))
|
|
self.assertEqual(answer['status'], 'needs_clarification')
|
|
self.assertIn('单位', answer['t'])
|
|
self.assertIsNone(turn.candidate)
|
|
|
|
def test_non_fba_address_fee_warning_survives_structured_followup(self):
|
|
initial = self.start(postal='90001', address_type='non_fba')
|
|
self.assertIn('住宅/商业派送附加费', initial['t'])
|
|
turn = QuoteTurn(self.cid, '改100KG', self.book)
|
|
answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'set', 'value': '100'}]))
|
|
self.assertEqual(answer['data']['confirmed_inputs']['address_type'], 'non_fba')
|
|
self.assertIn('住宅/商业派送附加费', answer['t'])
|
|
|
|
def test_legacy_country_switch_invalidates_old_model_context(self):
|
|
self.start()
|
|
self.assertIsNone(answer_quote('100KG发英国', self.cid))
|
|
db.save_message(self.cid, 'user', '100KG发英国')
|
|
turn = QuoteTurn(self.cid, '最快呢', self.book)
|
|
self.assertEqual(turn.context()['confirmed_inputs'], {})
|
|
self.assertEqual(turn.context()['options'], [])
|
|
answer = turn.run(self.arguments(shipment={}, selection='fastest'))
|
|
self.assertEqual(answer['status'], 'needs_clarification')
|
|
self.assertNotIn('1000KG', answer['t'])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|