Files

498 lines
28 KiB
Python

"""Structured shipment edits and quote facts, scoped to one original user turn."""
from copy import deepcopy
from decimal import Decimal, InvalidOperation
import hashlib
import json
import re
import sqlite3
from uuid import uuid4
from .conversation_state import empty_state, load_state
DECIMAL_SCHEMA = {'anyOf': [{'type': 'string'}, {'type': 'number'}]}
DIMENSIONS_SCHEMA = {'type': 'array', 'items': DECIMAL_SCHEMA, 'minItems': 3, 'maxItems': 3}
SHIPMENT_SCHEMA = {'type': 'object', 'additionalProperties': False, 'properties': {
'country': {'type': 'string', 'description': '明确目的国家的 ISO 两字母代码,例如 US、GB。'},
'total_weight_kg': DECIMAL_SCHEMA, 'total_volume_cbm': DECIMAL_SCHEMA,
'boxes': {'type': 'integer', 'minimum': 1, 'maximum': 10000},
'dimensions_cm': DIMENSIONS_SCHEMA,
'packages': {'type': 'array', 'maxItems': 200, 'items': {
'type': 'object', 'additionalProperties': False,
'properties': {'count': {'type': 'integer', 'minimum': 1},
'weight_kg': DECIMAL_SCHEMA, 'dimensions_cm': DIMENSIONS_SCHEMA},
'required': ['count', 'weight_kg']}},
'warehouse': {'type': 'string'}, 'postal': {'type': 'string'},
'zone': {'type': 'string', 'enum': ['美西', '美中', '美东', '东1区', '东2区', '中1区', '中2区', '西1区', '西2区']},
'origin': {'type': 'string', 'enum': ['义乌', '华南']},
'kinds': {'type': 'array', 'items': {'type': 'string', 'enum': ['parcel', 'truck']}},
'tax': {'type': 'string', 'enum': ['included', 'excluded']},
'address_type': {'type': 'string', 'enum': ['fba', 'non_fba'],
'description': '明确为亚马逊仓或非亚马逊的住宅/商业地址;未确认时省略。'},
'material': {'type': 'string', 'enum': ['ordinary', 'metal', 'unknown_metal']},
'metal_scope': {'type': 'string', 'enum': ['all', 'partial']},
'metal_chargeable_weight_kg': DECIMAL_SCHEMA,
}}
CHANGE_FIELDS = [key for key in SHIPMENT_SCHEMA['properties'] if key not in ('packages', 'kinds', 'dimensions_cm')]
CHANGES_SCHEMA = {'type': 'array', 'maxItems': 20, 'items': {
'type': 'object', 'additionalProperties': False,
'properties': {'field': {'type': 'string', 'enum': CHANGE_FIELDS},
'operation': {'type': 'string', 'enum': ['set', 'add']},
'value': DECIMAL_SCHEMA},
'required': ['field', 'operation', 'value']}}
FIELD_MAP = {'total_weight_kg': 'weight', 'total_volume_cbm': 'cbm',
'dimensions_cm': 'dims', 'metal_chargeable_weight_kg': 'metal_chargeable_weight'}
QUANTITIES = {'total_weight_kg', 'total_volume_cbm', 'metal_chargeable_weight_kg'}
def decimal_text(value, *, positive=True):
if isinstance(value, bool) or not isinstance(value, (str, int, float, Decimal)):
raise ValueError('数量必须是明确的数字。')
if len(str(value)) > 40:
raise ValueError('数量超出有效范围。')
try:
number = Decimal(str(value))
except InvalidOperation:
raise ValueError('数量必须是明确的数字。') from None
if not number.is_finite() or abs(number) > 1000000 or positive and number <= 0:
raise ValueError('请提供大于0且不超过1000000的有效数量。')
return format(number.normalize(), 'f')
def normalize_field(field, value):
if field in QUANTITIES:
return decimal_text(value)
if field == 'boxes':
if type(value) is not int or not 0 < value <= 10000:
raise ValueError('箱数必须是1至10000之间的整数。')
return value
if field == 'dimensions_cm':
if not isinstance(value, list) or len(value) != 3:
raise ValueError('请提供以厘米为单位的完整长宽高。')
result = [decimal_text(item) for item in value]
if any(Decimal(item) > 100000 for item in result):
raise ValueError('请提供有效的厘米箱规。')
return result
if field == 'packages':
if not isinstance(value, list) or not 1 <= len(value) <= 200:
raise ValueError('请提供有效的逐箱明细。')
result = []
for item in value:
if (not isinstance(item, dict) or not {'count', 'weight_kg'} <= set(item)
or set(item) - {'count', 'weight_kg', 'dimensions_cm'}):
raise ValueError('逐箱明细需要箱数和每箱重量,箱规使用厘米。')
piece = {'count': normalize_field('boxes', item['count']),
'weight_kg': decimal_text(item['weight_kg'])}
if 'dimensions_cm' in item:
piece['dimensions_cm'] = normalize_field('dimensions_cm', item['dimensions_cm'])
result.append(piece)
return result
if field == 'kinds':
if not isinstance(value, list) or not value or any(item not in ('parcel', 'truck') for item in value):
raise ValueError('当前运输方式仅支持海派或海卡。')
return list(dict.fromkeys(value))
if field not in SHIPMENT_SCHEMA['properties'] or not isinstance(value, str):
raise ValueError('包含不支持的货物字段。')
value = value.strip()
if not value or len(value) > 100:
raise ValueError('请提供有效的货物字段。')
choices = SHIPMENT_SCHEMA['properties'][field].get('enum')
if choices and value not in choices:
raise ValueError('货物条件不在当前支持的范围内。')
if field == 'country':
value = value.upper()
if not re.fullmatch(r'[A-Z]{2}', value):
raise ValueError('目的国家请使用明确的两字母代码。')
if field == 'warehouse':
value = value.upper()
if not re.fullmatch(r'[A-Z0-9_-]{2,20}', value):
raise ValueError('请核对目的仓码。')
if field == 'postal' and not re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?', value):
raise ValueError('美国邮编请提供5位数字,可附加4位扩展码。')
return value[:5] if field == 'postal' else value
def blank_intent(text=''):
keys = ('country weight dims cbm boxes packages allocations origin services kinds transport air_only air_requested '
'warehouse warehouses unknown_warehouses postal zone zones tax excluded_services clear_choice '
'choice_pending tax_pending quantity_change quantity_question pending_weight weight_unit material '
'metal_scope metal_chargeable_weight metal_weight_update address_type preserve_total')
result = {key: None for key in keys.split()}
result.update(dim_unit='cm', errors=[], mode='quote', selection=None, plan_reference=None,
plan_handled=False, clear_selection=False, quantity_uncertain=False,
volume_pending=False, compare_services=False, text=text, english=False)
return result
def public_inputs(intent):
from .pricing import dims_to_cm
result = {}
for field in SHIPMENT_SCHEMA['properties']:
key = FIELD_MAP.get(field, field)
value = intent.get(key)
if value is None:
continue
if field == 'country':
value = intent.get('_country_code') or ('US' if value == 'US' else None)
elif field == 'dimensions_cm':
value = [str(item) for item in dims_to_cm(value, intent.get('dim_unit', 'cm'))]
elif field == 'packages':
value = [{'count': item['count'], 'weight_kg': item['weight'],
**({'dimensions_cm': [str(number) for number in dims_to_cm(item['dims'], item.get('dim_unit', 'cm'))]}
if item.get('dims') else {})} for item in value]
if value is not None:
result[field] = deepcopy(value)
return result
def intent_from_inputs(shipment, text=''):
result = blank_intent(text)
for field, value in shipment.items():
if field == 'packages':
result['packages'] = [{'count': item['count'], 'weight': item['weight_kg'],
'dims': item.get('dimensions_cm'), 'dim_unit': 'cm'} for item in value]
else:
result[FIELD_MAP.get(field, field)] = deepcopy(value)
if result['country']:
result['_country_code'] = result['country']
result['country'] = 'US' if result['country'] == 'US' else 'other'
if result['warehouse']:
result['warehouses'] = [result['warehouse']]
result['address_type'] = result['address_type'] or 'fba'
if result['zone']:
result['zones'] = [result['zone']]
result['english'] = not bool(re.search(r'[\u4e00-\u9fff]', text)) and bool(re.search('[A-Za-z]{3,}', text))
return result
def legacy_state(conv_id):
"""Use saved conditions only while later messages have not crossed a topic boundary."""
if not conv_id:
return None
try:
state = load_state(conv_id)
except sqlite3.OperationalError as error:
if 'no such column' in str(error):
return None # Compatibility before the state migration has been installed.
raise
if not state or not state.get('intent'):
return None
from .db import connect
from .quote_intent import TOPIC_BOUNDARY, US
from .pricing import shipping_region
connection = connect()
try:
later = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id",
(conv_id, state.get('history_id', 0))).fetchall()
finally:
connection.close()
for (message,) in later:
if TOPIC_BOUNDARY.search(message) or shipping_region(message) is not None and not US.search(message):
return None
return state
def state_candidate(base, intent, book, options):
clean = deepcopy(intent)
clean.update(text='', errors=[], quantity_change=None, quantity_note=None,
quantity_question=None, plan_reference=None, plan_handled=False)
return {**deepcopy(base), 'schema': 1, 'shipment_id': base.get('shipment_id') or uuid4().hex,
'intent': clean, 'options': deepcopy(options), 'book_id': book['id'] if book else None}
def migrate_history(conv_id, book):
"""Recover only past messages, never reapply this turn's amendment while migrating."""
from .quote_service import previous_quote_intents
from .quote_context import merge_intent
codes = {row['code'] for row in book['warehouses']}
codes.update(code.upper() for rate in book['rates'] for code in rate['destinations'])
previous = previous_quote_intents(conv_id, codes) if conv_id else []
if not previous:
return blank_intent()
result = previous[-1]
for update in reversed(previous[:-1]):
result = merge_intent(update, result)
return result
def option_facts(intent, book, shipment_id):
"""Return exact calculated rate facts; these are not inferred display ordinals."""
from .quote_data import postcode_fee
from .quote_engine import variants, match_rates, quantity, select_timed_rates, select_cheapest_rates
from .quote_fees import charge_details
results = []
for destination in variants(intent, book):
if destination.get('allocation'):
continue # Legacy allocation quotes remain supported but are not guessed into new references.
fee = postcode_fee(book['id'], destination['postal']) if destination['postal'] else None
rates = match_rates(intent, book, destination)
selection = intent.get('selection') or intent.get('mode')
if selection in ('fastest', 'slowest'):
rates = select_timed_rates(rates, slowest=selection == 'slowest', by_origin=intent.get('mode') == 'quote_detail')
elif selection == 'cheapest':
rates = select_cheapest_rates(intent, book, rates, fee, zone=destination['zone']) or rates
elif selection in ('speed_compare', 'fastest_cheapest'):
fast = select_timed_rates(rates, by_origin=intent.get('mode') == 'quote_detail')
other = (select_timed_rates(rates, slowest=True, by_origin=intent.get('mode') == 'quote_detail')
if selection == 'speed_compare' else select_cheapest_rates(intent, book, rates, fee, zone=destination['zone']))
rates = fast + [rate for rate in other if rate not in fast]
for rate in rates:
amount = quantity(intent, rate)
if amount is None:
continue
details = charge_details(intent, rate, fee, book)
identity = [book['id'], shipment_id, public_inputs(intent), destination, rate]
option_id = 'quote_' + hashlib.sha256(json.dumps(identity, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()[:24]
base_freight = amount * Decimal(rate['price'])
results.append({'option_id': option_id, 'service': rate['service'], 'origin': rate['origin'],
'tax': rate['tax'], 'unit': rate['unit'], 'kind': rate['kind'], 'currency': 'CNY',
'quantity': str(amount), 'unit_price': rate['price'], 'base_freight': str(base_freight),
'known_surcharges': str(details['cny']), 'known_subtotal': str(base_freight + details['cny']),
'eta': rate['eta'], 'destination': deepcopy(destination),
'warnings': details['notes'], 'source_sheet': rate.get('source_sheet'), 'source_cell': rate.get('source_cell'),
'data_version': book['id']})
return results
class QuoteTurn:
def __init__(self, conv_id, question, book):
self.conv_id, self.question, self.book = conv_id, question, deepcopy(book)
self.base_state = load_state(conv_id) if conv_id else empty_state()
if self.base_state and self.base_state.get('intent'):
from .quote_intent import resumes_quote
if not resumes_quote(question) and legacy_state(conv_id) is None:
self.base_state = {**self.base_state, 'intent': None, 'options': [], 'shipment_id': None}
self.expected_version = self.base_state['version'] if self.base_state else None
self.candidate = None
self._applied = {}
self._new = False
self._conflict = False
self._intent = deepcopy(self.base_state.get('intent')) if self.base_state else None
self._shipment_id = (self.base_state or {}).get('shipment_id') or uuid4().hex
self._options = []
self._legacy_cache = {}
def context(self):
state = self.candidate or self.base_state
if state is None:
return {'status': 'forbidden'}
current_book = self.book['id'] if self.book else None
return {'confirmed_inputs': public_inputs(state['intent']) if state.get('intent') else {},
'options': deepcopy(state.get('options', [])) if state.get('book_id') == current_book else [],
'data_version': current_book}
def replay_confirmed(self, state):
"""A login retry may reread the original quote without changing current state."""
self.base_state = {**deepcopy(state), 'version': self.expected_version}
self._intent = deepcopy(state['intent'])
self._shipment_id = state['shipment_id']
def _result(self, text, status, intent=None, options=None, missing=None):
confirmed = public_inputs(intent or self._intent or {})
valid_candidate = self.candidate is not None or (
not self.conv_id and intent is not None and not self._conflict and status != 'error')
basis = None
if valid_candidate:
conditions = {'shipment_id': self._shipment_id, 'inputs': confirmed,
'allocations': (intent or self._intent or {}).get('allocations')}
basis = hashlib.sha256(json.dumps(conditions, sort_keys=True, ensure_ascii=False,
default=str).encode()).hexdigest()[:24]
return {'t': text, 'status': status, 'data': {'confirmed_inputs': confirmed, 'options': options or []},
'_confirmed_quote': confirmed, '_quote_basis': basis, 'missing_fields': missing or [],
'data_version': self.book['id'] if self.book else None}
def run(self, arguments):
if self.base_state is None:
return self._result('该会话暂时无法访问,请新建对话后重试。', 'error')
if self.conv_id:
current = load_state(self.conv_id)
if current is None:
self.candidate = None
return {'t': '该会话暂时无法访问,请新建对话后重试。', 'status': 'error',
'data': {}, 'data_version': None}
if current['version'] != self.expected_version:
self.candidate = None
return self._result('会话条件已被另一条消息更新,请按最新条件重新查询。', 'needs_clarification')
if self._conflict:
return self._result('同一条消息中的货物更正相互冲突,请确认本次修改后的条件。', 'needs_clarification')
if not self.book:
return self._result('当前尚未包含可用的报价资料,请联系人工确认。', 'not_found')
try:
return self._run(arguments)
except (ValueError, InvalidOperation) as error:
return self._result(str(error), 'needs_clarification')
except TypeError:
return self._result('货物条件暂时无法核对,请确认重量、箱数及箱规后重试。', 'error')
def _run(self, arguments):
from .quote_service import answer_quote_intent, intent_with_context
question = arguments.get('question') or self.question
structured = any(key in arguments for key in ('shipment', 'changes', 'new_shipment', 'option_id'))
shipment = arguments.get('shipment', {})
changes = arguments.get('changes', [])
if not isinstance(shipment, dict) or set(shipment) - set(SHIPMENT_SCHEMA['properties']):
raise ValueError('包含不支持的货物字段。')
if not isinstance(changes, list) or len(changes) > 20 or type(arguments.get('new_shipment', False)) is not bool:
raise ValueError('货物修改格式无效。')
edits = {key: [('set', normalize_field(key, value))] for key, value in shipment.items()}
for change in changes:
if not isinstance(change, dict) or set(change) != {'field', 'operation', 'value'}:
raise ValueError('货物修改需要字段、操作和数值。')
field, operation = change['field'], change['operation']
if field not in CHANGE_FIELDS or operation not in ('set', 'add'):
raise ValueError('不支持该货物修改方式。')
if operation == 'add':
if field not in ('total_weight_kg', 'total_volume_cbm', 'boxes'):
raise ValueError('只有重量、体积和箱数支持增减。')
value = decimal_text(change['value'], positive=False)
if field == 'boxes' and Decimal(value) != Decimal(value).to_integral_value():
raise ValueError('增减箱数必须是整数。')
else:
value = normalize_field(field, change['value'])
actions = edits.setdefault(field, [])
action = (operation, value)
if action not in actions:
if any(existing[0] == operation for existing in actions):
raise ValueError('同一字段给出了不同的修改值,请确认本次修改后的条件。')
actions.append(action)
for field, actions in edits.items():
signature = json.dumps(actions, sort_keys=True)
if field in self._applied and self._applied[field] != signature:
self._conflict, self.candidate = True, None
raise ValueError('同一条消息中的货物更正相互冲突,请确认本次修改后的条件。')
new = arguments.get('new_shipment', False)
new_created = False
if new and not self._new:
if self._applied:
self._conflict, self.candidate = True, None
raise ValueError('请确认本次是在修改原货物,还是新的一票货物。')
self._intent, self._shipment_id, self._new = blank_intent(question), uuid4().hex, True
new_created = True
if self._intent is None:
# Migration only: legacy conversations may have established conditions.
# New structured shipments never go through text-to-intent parsing.
self._intent = (blank_intent(question) if shipment or new else migrate_history(self.conv_id, self.book)) if structured else intent_with_context(question, self.conv_id, self.book)
if not structured:
if question not in self._legacy_cache:
self._legacy_cache[question] = intent_with_context(question, self.conv_id, self.book)
self._intent = deepcopy(self._legacy_cache[question])
current = public_inputs(self._intent)
updated_fields = set()
for field, actions in edits.items():
if field in self._applied:
continue
value = current.get(field)
for operation, operand in actions:
if operation == 'set':
value = operand
else:
if value is None:
raise ValueError('请先确认原来的总重、体积或箱数,再说明增减量。')
number = Decimal(str(value)) + Decimal(operand)
value = normalize_field(field, int(number) if field == 'boxes' else str(number))
if value != current.get(field):
updated_fields.add(field)
current[field] = value
if 'country' in updated_fields and public_inputs(self._intent).get('country') not in (None, current['country']):
current = {field: current[field] for field in edits}
self._shipment_id = uuid4().hex
if updated_fields & {'warehouse', 'postal', 'zone'}:
for field in {'warehouse', 'postal', 'zone'} - set(edits):
current.pop(field, None)
if 'total_weight_kg' in updated_fields and 'packages' not in edits:
current.pop('packages', None)
if 'boxes' in updated_fields and 'packages' not in edits:
current.pop('packages', None)
if 'total_weight_kg' not in edits:
current.pop('total_weight_kg', None)
if 'dimensions_cm' in updated_fields:
current.pop('total_volume_cbm', None)
if 'packages' in current:
for piece in current['packages']:
piece['dimensions_cm'] = current['dimensions_cm']
if 'total_volume_cbm' in updated_fields:
current.pop('dimensions_cm', None)
for piece in current.get('packages', []):
piece.pop('dimensions_cm', None)
if updated_fields & {'total_weight_kg', 'total_volume_cbm', 'boxes', 'dimensions_cm', 'packages'}:
if 'metal_chargeable_weight_kg' not in edits:
current.pop('metal_chargeable_weight_kg', None)
if current.get('material') == 'ordinary':
current.pop('metal_scope', None)
current.pop('metal_chargeable_weight_kg', None)
if current.get('packages'):
count = sum(piece['count'] for piece in current['packages'])
weight = sum(Decimal(piece['weight_kg']) * piece['count'] for piece in current['packages'])
if count > 10000 or current.get('boxes', count) != count or Decimal(current.get('total_weight_kg', str(weight))) != weight:
raise ValueError('总箱数或总重与逐箱明细不一致,请确认。')
current.update(boxes=count, total_weight_kg=decimal_text(weight))
invalidates_allocation = updated_fields & {'country', 'total_weight_kg', 'total_volume_cbm', 'boxes', 'dimensions_cm', 'packages', 'warehouse', 'postal', 'zone'}
if self._intent.get('allocations') and invalidates_allocation and not new:
raise ValueError('原货物按多个目的仓分票,请分别确认各仓重量和箱规,不能把整票修改自动分配到各仓。')
intent = intent_from_inputs(current, question) if structured or edits else deepcopy(self._intent)
if structured and 'country' not in updated_fields and not new:
intent['services'] = deepcopy(self._intent.get('services'))
if not invalidates_allocation:
for key in ('allocations', 'warehouses', 'zones', 'plan_unit', 'plan_destination'):
if self._intent.get(key) is not None:
intent[key] = deepcopy(self._intent[key])
intent['text'] = question
if structured or edits:
intent['errors'] = []
if intent.get('errors'):
return self._result('\n'.join(intent['errors']), 'needs_clarification', intent)
request = {key: arguments.get(key, default) for key, default in
(('request', 'quote'), ('selection', 'current'), ('services', []), ('policy_topic', 'auto'))}
if request['selection'] != 'current':
intent['services'] = request['services'] or None
intent.pop('plan_unit', None)
intent.pop('plan_destination', None)
elif request['services']:
intent['services'] = request['services']
option_id = arguments.get('option_id')
if option_id:
option = next((option for option in self.base_state['options'] if option.get('option_id') == option_id), None)
if (not option or self.base_state.get('book_id') != self.book['id'] or updated_fields or new or self._applied or self._new):
raise ValueError('所选方案已失效或条件发生变化,请重新查看报价后选择。')
intent.update(services=[option['service']], origin=option['origin'], tax=option['tax'],
kinds=[option['kind']], plan_unit=option['unit'])
destination = option['destination']
intent.update(warehouse=destination['warehouse'], postal=destination['postal'], zone=destination['zone'])
request.update(selection='current', services=[option['service']])
for field, actions in edits.items():
self._applied[field] = json.dumps(actions, sort_keys=True)
if updated_fields or new_created:
self._options = []
self._intent = deepcopy(intent)
base = {**self.base_state, 'shipment_id': self._shipment_id}
if intent.get('country') != 'US':
self.candidate = state_candidate(base, intent, self.book, []) if self.conv_id else None
return self._result('请确认目的国家;当前结构化报价仅支持已导入的美国资料,其他目的地需人工核对。',
'needs_clarification' if not intent.get('country') else 'unsupported', intent, missing=['country'] if not intent.get('country') else [])
known_codes = {row['code'] for row in self.book['warehouses']}
known_codes.update(code.upper() for rate in self.book['rates'] for code in rate['destinations'])
if intent['warehouse'] and intent['warehouse'] not in known_codes:
intent['unknown_warehouses'] = [intent['warehouse']]
answer = answer_quote_intent(intent, self.book, request) or {'t': '请补充货物条件,以便继续核对报价。'}
# Apply the same selection to exact rate facts without parsing generated prose.
fact_intent = answer.get('_quote_intent', intent)
can_quote = answer.get('_quote_calculated') and request['request'] != 'policy'
options = option_facts(fact_intent, self.book, self._shipment_id) if can_quote else []
if request['request'] == 'policy' and not self._options and not updated_fields and not new_created and self.base_state.get('book_id') == self.book['id']:
self._options = deepcopy(self.base_state['options'])
self._options = list({option['option_id']: option for option in self._options + options}.values())
self.candidate = state_candidate(base, intent, self.book, self._options) if self.conv_id else None
missing = [] if request['request'] in ('time', 'policy') or any(intent.get(key) for key in ('weight', 'cbm', 'dims', 'packages')) else ['total_weight_kg']
status = 'needs_clarification' if missing else 'ok'
if intent.get('unknown_warehouses'):
status = 'not_found'
elif answer.get('status') == 'error':
status = 'error'
elif request['request'] not in ('policy', 'time') and not options and not missing:
status = 'not_found' if answer.get('_quote_calculated') else 'needs_clarification'
return self._result(answer['t'], status, intent, options, missing)