diff --git a/AGENTS.md b/AGENTS.md index a6cd684..4e192e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees | | US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` | | Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries | -| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals and subset questions; `order_tracking.py`: `current_stage` | +| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions and quantity adjustments; `quote_comparison.py`: comparable fast/slow costs; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals, adjacent rows and subset questions; `order_tracking.py`: `current_stage` | | Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | | Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` | | Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` | diff --git a/docs/architecture.md b/docs/architecture.md index bcb3cfc..d179a72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,8 @@ tests/ # 按业务模块组织的回归 连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。 +报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。 + - 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。 - `db.py` 统一连接、schema 和历史持久化。人工与管理业务的 SQL 放在所属服务,HTTP 路由不执行 SQL。请求及流式请求结束时关闭连接,未提交事务回滚。 - `init_db` 包括客服账号、订单与留言表,首次启动只建空表;不内置账号。重复初始化保留已有数据,业务测试从真实初始化结果插入夹具,不另建缺失表。 diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 94c2b9f..0be1c49 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -23,7 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipmen 'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级', 'parent':'父订单号', 'updated':'来源更新时间'} IDENTIFIER = re.compile(r'(?1: + fields=card.get('fields') + if not isinstance(fields,list):return None + inbound=next((field.get('value') for field in fields if isinstance(field,dict) and field.get('key')=='inbound'),None) + matches=[index for index in matches if shown[index-1][0]==inbound] + return matches[0] if len(matches)==1 else None + except (ValueError,TypeError,AttributeError): + return None + + def answer_followup(message,tenants,conv_id): from .customer_service import order_login_response from .order_listing import load_state,answer_summary text=message.strip(' ,,。.!!??') ordinal=ordinal_request(text) + step=list_step(text) relative=is_relative_question(text) reason=bool(re.fullmatch(r'(?:那|这些|这几票|它们|上述|上面)?(?:订单|运单)?' r'(?:为什么异常|为什么有异常|异常的?原因(?:是什么|有哪些|呢)?|异常是怎么回事)',text)) @@ -62,10 +90,22 @@ def answer_followup(message,tenants,conv_id): short_count=(not re.search(NOUN,text,re.I) and not re.search(COUNT,text,re.I) and intent is not None and not intent['unsupported'] and any(intent.get(key) for key in ('status','stage','exception','grouped'))) - if ordinal is None and not relative and not reason and not short_count:return None + if ordinal is None and step is None and not relative and not reason and not short_count:return None if not tenants:return order_login_response() try: previous=load_state(conv_id) + if step is not None: + if not previous:return {'t':'请先查看订单列表并选择一票,或直接提供入仓码。'} + selected=previous.get('selected_index') + if selected is None:return {'t':'请先在当前列表中选择一票,例如“第一个”,再继续查看上一票或下一票。'} + connection=connect() + try:shown=shown_references(connection,conv_id,previous) + finally:connection.close() + ordinal=selected+step + if not 1<=ordinal<=len(shown): + command='下一页' if step>0 else '上一页' + edge='最后一票' if step>0 else '第一票' + return {'t':f'当前展示列表已到{edge}。可回复“{command}”翻页,或提供入仓码查询其他订单。'} if ordinal is not None: if not previous:return {'t':'请先查看需要查询的订单列表,或直接提供入仓码。'} connection=connect() @@ -75,9 +115,11 @@ def answer_followup(message,tenants,conv_id): if not shown:return {'t':'当前列表没有可查询的订单,请重新指定筛选条件,或直接提供入仓码。'} if not 1<=ordinal<=len(shown): return {'t':f'刚才的列表展示了{len(shown)}票,请选择其中一票,或回复“查看全部”展开列表后再查询。'} - inbound,number=shown[ordinal-1] - return {'_order_reference':number if inbound=='未登记' else inbound} - if short_count and not relative and (not previous or previous.get('selected')):return None + return {'_order_reference':shown[ordinal-1][1]} + if short_count and not relative and (not previous or previous.get('selected')): + from .order_data import is_order_followup + if is_order_followup(text):return None + return {'t':'请先说明要统计的订单范围,例如“我的异常订单有多少”,或提供入仓码查询单票情况。'} if reason: if not previous or previous.get('selected'):return None intent={**previous['query'],'exception':True,'unsupported':False} diff --git a/logistics_chat/order_listing.py b/logistics_chat/order_listing.py index 4dadfa4..dca5d67 100644 --- a/logistics_chat/order_listing.py +++ b/logistics_chat/order_listing.py @@ -31,8 +31,9 @@ def load_state(conv_id): from .customer_service import current_customer_id,current_scope_key from .visitor_service import visitor_id from .customer_faq import is_acknowledgement - from .order_followups import ordinal_request,shown_references,is_relative_question + from .order_followups import ordinal_request,list_step,shown_references,selected_row,is_relative_question from .order_data import is_order_followup + from .tracking import CARD_PREFIX if not conv_id:return None connection=connect() try: @@ -51,13 +52,22 @@ def load_state(conv_id): or item['inbound_code'] is not None and not isinstance(item['inbound_code'],str) for item in shown): return None state['selected']=False - for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id", - (conv_id,state['message_id'])): + state['selected_index']=None + visible=shown_references(connection,conv_id,state) + for role,text in connection.execute('SELECT role,content FROM messages WHERE conv_id=? AND id>? ORDER BY id', + (conv_id,state['message_id'])): + if role!='user': + if role=='assistant' and text.startswith(CARD_PREFIX): + state['selected_index']=selected_row(text,visible) + state['selected']=state['selected_index'] is not None + continue if is_acknowledgement(text):continue if is_relative_question(text):continue ordinal=ordinal_request(text) if ordinal is not None: - state['selected']=1<=ordinal<=len(shown_references(connection,conv_id,state)) + continue + step=list_step(text) + if step is not None: continue if state['selected'] and is_order_followup(text):continue if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'): diff --git a/logistics_chat/quote_comparison.py b/logistics_chat/quote_comparison.py new file mode 100644 index 0000000..33e0dff --- /dev/null +++ b/logistics_chat/quote_comparison.py @@ -0,0 +1,34 @@ +"""Compare calculable CNY costs on the same destination and billing basis.""" + +from decimal import Decimal,ROUND_HALF_UP + +from .quote_fees import charge_details + + +def speed_difference_lines(intent, book, rates, fees): + from .quote_engine import quantity, number, select_timed_rates + + groups = {} + for rate in rates: + key = (rate['origin'], rate['zone'], rate['kind'], rate['unit'], rate['tax']) + groups.setdefault(key, []).append(rate) + lines = [] + for (origin, zone, kind, unit, tax), candidates in groups.items(): + costs = [] + for selected in (select_timed_rates(candidates), select_timed_rates(candidates, slowest=True)): + values = [] + for rate in selected: + amount = quantity(intent, rate) + if amount is not None: + total=amount * Decimal(rate['price']) + charge_details(intent, rate, fees, book)['cny'] + values.append(total.quantize(Decimal('.01'),rounding=ROUND_HALF_UP)) + costs.append(values) + fast, slow = costs + if not fast or not slow: + continue + low, high = min(fast) - max(slow), max(fast) - min(slow) + label = ' · '.join(value for value in (origin, zone, '包税' if tax == 'included' else '不包税', + '海派' if kind == 'parcel' else '海卡/海铁') if value) + difference = number(low) if low == high else number(low) + '至' + number(high) + lines.append(f'- {label},快慢方案可计算运费差额为{difference}元。') + return lines diff --git a/logistics_chat/quote_context.py b/logistics_chat/quote_context.py index eb392ff..430230a 100644 --- a/logistics_chat/quote_context.py +++ b/logistics_chat/quote_context.py @@ -4,27 +4,167 @@ from copy import deepcopy from decimal import Decimal from .quote_intent import kg,MISSING_WEIGHT_UNIT +from .quote_edits import ADJUSTMENT_QUESTION,VOLUME_QUESTION QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。' +def apply_quantity_change(result,current,previous): + from .quote_engine import exact_number + + change=current.get('quantity_change') + if not change:return False + if change['kind']=='volume': + updated=Decimal(current['cbm']) if current['cbm'] is not None else None + mismatch=False + if not change['uncertain'] and previous['cbm'] is not None and not previous.get('volume_pending'): + expected=Decimal(previous['cbm'])+Decimal(change['delta']) + if updated is not None and updated!=expected: + mismatch=True + result['errors'].append('增减体积与您提供的新总体积不一致,请确认调整后的总体积。') + else:updated=expected + if change['uncertain'] or updated is None or updated<=0 or mismatch: + result['volume_pending']=True + result['errors'].append(VOLUME_QUESTION) + else: + result['cbm']=str(updated) + result['volume_pending']=False + result['quantity_note']=f'按本次增减更新,总体积为{exact_number(updated)}CBM。' + return False + question=ADJUSTMENT_QUESTION + result['quantity_question']=question + result['quantity_uncertain']=True + if change['kind']=='boxes' and (previous['cbm'] is not None or previous.get('volume_pending') or current['cbm'] is not None): + result['volume_pending']=True + if change['uncertain'] or previous.get('allocations'): + result['errors'].append(question) + return False + if change['kind']=='weight': + if previous.get('weight') is None or previous.get('quantity_uncertain') or previous.get('pending_weight'): + if current['weight'] is None: + result['errors'].append(question) + return False + updated=Decimal(current['weight']) + else: + updated=Decimal(previous['weight'])+Decimal(change['delta']) + if current['weight'] is not None and Decimal(current['weight'])!=updated: + result['errors'].append('增减量与您提供的新总重不一致,请确认调整后的总重。') + return False + if updated<=0: + result['errors'].append('调整后的总重需要大于0,请确认增减量或新的总重。') + return False + result['weight']=str(updated) + else: + pieces=deepcopy(previous.get('packages') or []) + if not pieces or previous.get('quantity_uncertain') or previous.get('pending_weight'): + result['errors'].append(question) + return False + for piece in pieces: + if not piece.get('dims') and previous['dims']: + piece.update(dims=previous['dims'],dim_unit=previous['dim_unit']) + first=pieces[0] + uniform=all(Decimal(piece['weight'])==Decimal(first['weight']) + and all(piece.get(key)==first.get(key) for key in ('dims','dim_unit')) for piece in pieces) + if uniform: + first['count']=sum(piece['count'] for piece in pieces) + pieces=[first] + delta=change['delta'] + additions=deepcopy(current.get('packages') or []) + target_count=sum(piece['count'] for piece in pieces)+delta + declared_count=change.get('declared_boxes') + if declared_count is not None and declared_count!=target_count: + result['errors'].append('增减箱数与您提供的新总箱数不一致,请确认调整后的箱数。') + return False + if declared_count is not None and additions and sum(piece['count'] for piece in additions)==target_count: + pieces=additions + elif delta>0 and additions: + if sum(piece['count'] for piece in additions)!=delta: + result['errors'].append(question) + return False + pieces.extend(additions) + elif uniform: + if delta<0 and additions and any(Decimal(piece['weight'])!=Decimal(first['weight']) + or piece.get('dims') and (piece['dims']!=first.get('dims') or piece['dim_unit']!=first.get('dim_unit')) for piece in additions): + result['errors'].append('减少箱子的重量与原分箱明细不一致,请提供更新后的逐箱明细。') + return False + first['count']+=delta + if first['count']<=0: + result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。') + return False + else: + result['errors'].append('原货物包含不同箱规,请提供更新后的逐箱数量和重量。') + return False + result['packages']=pieces + result['boxes']=sum(piece['count'] for piece in pieces) + result['weight']=str(sum(Decimal(piece['weight'])*piece['count'] for piece in pieces)) + result['dims']=None + result['cbm']=None + if result['boxes']<=0 or any(piece['count']<=0 for piece in pieces): + result['errors'].append('调整后的箱数需要大于0,请确认增减量或新的箱数。') + return False + if change.get('declared_weight') is not None and Decimal(change['declared_weight'])!=Decimal(result['weight']): + result['errors'].append('增减箱数推算的总重与您提供的新总重不一致,请确认更新后的分箱明细。') + return False + result['quantity_uncertain']=False + result['quantity_question']=None + verb='增加' if Decimal(change['delta'])>0 else '减少' + delta=exact_number(abs(Decimal(change['delta']))) + if change['kind']=='boxes': + old_count=sum(piece['count'] for piece in previous['packages']) + result['quantity_note']=f'按原{old_count}箱{verb}{delta}箱核算,共{result["boxes"]}箱。' + elif previous['weight'] is not None and not previous.get('quantity_uncertain'): + result['quantity_note']=f'按原总重{exact_number(previous["weight"])}KG{verb}{delta}KG核算。' + else:result['quantity_note']='按您确认的新总重核算。' + return True + + def merge_intent(current,previous): result=deepcopy(current) - excluded={'mode','selection','clear_selection','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'} + excluded={'mode','selection','clear_selection','clear_choice','compare_services','choice_pending','excluded_services','quantity_change','tax_pending','volume_pending','quantity_question', + 'text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update','pending_weight','weight_unit'} unit=current.get('weight_unit') pending=previous.get('pending_weight') weight_change=current['weight'] is not None or bool(unit and pending is not None) + weight_change=apply_quantity_change(result,current,previous) or weight_change + change=current.get('quantity_change') + if change and change['kind']=='boxes':excluded.update(('dims','cbm','allocations')) + if change and change['kind']=='volume':excluded.add('dims') + volume_confirmed=not (change and change['kind']=='boxes') and (current['cbm'] is not None or current['dims'] + or current.get('packages') and all(piece.get('dims') for piece in current['packages'])) + if previous.get('volume_pending') and not volume_confirmed:result['volume_pending']=True + if result.get('volume_pending'): + result['cbm']=None + excluded.add('cbm') + result['errors'].append(VOLUME_QUESTION) if unit: if pending is None:result['errors'].append('请同时提供重量数值和单位,例如800KG。') else:result['weight']=str(kg(pending,unit)) elif pending is not None and current['weight'] is None and current.get('pending_weight') is None: result['pending_weight']=pending result['errors'].append(MISSING_WEIGHT_UNIT) - if not current.get('selection') and not current.get('clear_selection'): + if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice'): result['selection']=previous.get('selection') if current.get('clear_selection'): excluded.update(('services','kinds','transport','air_only','air_requested')) + if current.get('compare_services'):excluded.add('services') + blocked=[] if current.get('clear_selection') else previous.get('excluded_services') or [] + blocked=list(dict.fromkeys(blocked+(current.get('excluded_services') or []))) + if current.get('excluded_services'): + excluded.add('services') + if any(name in ('海派','海卡','海铁') for name in current['excluded_services']):excluded.add('kinds') + positives=(current.get('services') or [])+[name for name in ('海派','海卡','海铁') if name in current['text']] + result['excluded_services']=[name for name in blocked if not any(name in choice or choice in name for choice in positives)] or None + if current.get('tax_pending') or previous.get('tax_pending') and current['tax'] is None: + result['tax_pending']=True + result['tax']=None + excluded.add('tax') + result['errors'].append('请明确选择包税或不包税方案。') + if current.get('choice_pending') or (previous.get('choice_pending') and not current.get('selection') + and not current.get('services') and not current.get('clear_selection')): + result['choice_pending']=True + result['selection']=None + result['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。') if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None: result['metal_chargeable_weight']=None result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。') @@ -56,13 +196,14 @@ def merge_intent(current,previous): if current['dims'] is None and 'dims' not in excluded and previous['dims']: result['dim_unit']=previous['dim_unit'] pieces=result.get('packages') or [] - if current['dims']: + if current['dims'] and not (change and change['kind']=='boxes'): for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit']) - elif current['cbm'] is not None: + elif (current['cbm'] is not None or change and change['kind']=='volume') and not (change and change['kind']=='boxes'): for piece in pieces:piece.update(dims=None,dim_unit='cm') if previous.get('quantity_uncertain') and not weight_change and not current.get('preserve_total'): result['quantity_uncertain']=True - result['errors'].append(QUANTITY_QUESTION) + result['quantity_question']=previous.get('quantity_question') or QUANTITY_QUESTION + result['errors'].append(result['quantity_question']) if pieces and result['boxes']!=sum(p['count'] for p in pieces): if current.get('preserve_total'): result['packages']=None @@ -74,4 +215,5 @@ def merge_intent(current,previous): result['packages']=None result['quantity_uncertain']=True result['errors'].append(QUANTITY_QUESTION) + result['errors']=list(dict.fromkeys(result['errors'])) return result diff --git a/logistics_chat/quote_edits.py b/logistics_chat/quote_edits.py index d0a1d69..fc1d36b 100644 --- a/logistics_chat/quote_edits.py +++ b/logistics_chat/quote_edits.py @@ -3,11 +3,82 @@ import re +SERVICE_TERMS = ('美森正班','美森加班','美森','EXX','合德','普船','海派','海卡','海铁') +CHANGE_VERBS = r'增加|追加|加上|加|减少|减掉|减去|减|去掉' +ADJUSTMENT_QUESTION = '请提供调整后的总重和箱数,或逐箱重量明细,我再继续核算。' +VOLUME_QUESTION = '请确认调整后的总体积,或提供更新后的逐箱尺寸,我再核算体积重。' + + +def condition_edits(text): + rejected = r'(?:不是|不要|并非|不从|不走|别走|不用|不选|别选)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到|走|选|用|按)?\s*' + service = r'(?:(?:洛杉矶|纽约|芝加哥|休斯顿|萨凡纳)?(?:美森正班|美森加班|美森|EXX|合德|普船)(?:海派|海卡|海铁|全卡)?|海派|海卡|海铁)' + value = (service+r'|不包税|包税|最快|最慢|最便宜|' + r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|' + r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品') + excluded = [] + taxes = set() + clear_choice = False + + def discard(match): + nonlocal clear_choice + if re.match(r'\s*(?:的)?(?:吗|嘛|么|[??])', text[match.end():]): + return match[0] + condition = match['value'] + term = next((name for name in SERVICE_TERMS if name.lower() in condition.lower()), None) + if term: + excluded.append(term) + if condition in ('包税','不包税'): + taxes.add(condition) + if condition in ('最快','最慢','最便宜'): + clear_choice = True + return ' ' + + cleaned = re.sub(rejected+'(?P'+value+')', discard, text, flags=re.I) + tax = 'included' if taxes == {'不包税'} else 'excluded' if taxes == {'包税'} else None + return {'text':cleaned, 'excluded_services':list(dict.fromkeys(excluded)) or None, + 'tax':tax, 'tax_pending':len(taxes)>1, 'clear_choice':clear_choice} + + def clean_conditions(text): - rejected=r'(?:不是|不要|并非|不从)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到)?\s*' - value=(r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|' - r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品') - return re.sub(rejected+'(?:'+value+')',' ',text,flags=re.I) + return condition_edits(text)['text'] + + +def quantity_adjustment(text): + """Separate a relative increment from an explicitly supplied new total.""" + from .quote_intent import WEIGHT,kg,normalize + + text=normalize(text) + prefix = re.match(r'^(?:(?:那(?:么)?|我(?:们)?(?:想|要)?|这次|现在|请帮我|帮我|麻烦|请|总重|重量|箱数|总体积|体积)[,,\s]*)*' + r'(?:再)?('+CHANGE_VERBS+r')\s*', text) + if not prefix: + unmatched=re.search(r'(?:'+CHANGE_VERBS+r')\s*-?\d+(?:\.\d+)?\s*(KG|公斤|千克|吨|箱|件|CBM|立方米|立方|方)',text,re.I) + if unmatched: + kind='volume' if unmatched[1].lower() in ('cbm','立方米','立方','方') else 'unknown' + return {'kind':kind,'delta':None,'text':text,'uncertain':True} + return None + subtract = prefix[1] in ('减少','减掉','减去','减','去掉') + sign = -1 if subtract else 1 + weight = WEIGHT.match(text, prefix.end()) + boxes = re.match(r'(\d+)\s*(?:个)?(?:箱|件)', text[prefix.end():]) + volume = re.match(r'(-?\d+(?:\.\d+)?)\s*(?:CBM|立方米|立方|方)',text[prefix.end():],re.I) + if weight: + remainder = text[weight.end():] + return {'kind':'weight', 'delta':str(sign*kg(*weight.groups())), + 'text':remainder, 'uncertain':kg(*weight.groups())<=0 or bool(re.search(CHANGE_VERBS+r'\s*\d',remainder))} + if boxes: + total_weight=re.search(r'(?:新总重|总重量|总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?1:parsed['dims']=None elif assigned: @@ -158,7 +165,7 @@ def parse_intent(message,warehouse_codes): remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining) match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining) if match:postal=match.group(1) - cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I) + cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',quantity_text,re.I) origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None) if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()] @@ -179,7 +186,14 @@ def parse_intent(message,warehouse_codes): 'air_only':True if air and not sea else None,'air_requested':True if air else None, 'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None, 'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None, - 'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None), + 'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else edits['tax']), + 'excluded_services':edits['excluded_services'],'clear_choice':edits['clear_choice'], + 'compare_services':bool(re.search(r'更快|快一?点|更便宜|便宜一?点',text)), + 'choice_pending':edits['clear_choice'] and request_selection(text) is None and not services, + 'tax_pending':edits['tax_pending'],'quantity_change':change, + 'quantity_uncertain':bool(change and change['kind']!='volume'), + 'volume_pending':bool(change and change['kind']=='volume'), + 'quantity_question':(VOLUME_QUESTION if change['kind']=='volume' else ADJUSTMENT_QUESTION) if change else None, 'errors':errors,'mode':mode,'selection':request_selection(text), 'pending_weight':pending_weight[1] if pending_weight else None, 'weight_unit':weight_unit[1] if weight_unit else None, @@ -194,9 +208,11 @@ def parse_intent(message,warehouse_codes): def is_followup(message,intent): if resumes_quote(message):return True - if intent.get('weight_unit'):return True if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True if TOPIC_BOUNDARY.search(message):return False + if intent.get('weight_unit') or intent.get('quantity_change'):return True + if intent.get('excluded_services') or intent.get('clear_choice') or intent.get('compare_services') or intent.get('tax_pending'): + return not (intent['country'] and intent['weight']) text=message.strip().strip(',,。.!!?? ') if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True diff --git a/logistics_chat/quote_service.py b/logistics_chat/quote_service.py index 895937e..eaea95b 100644 --- a/logistics_chat/quote_service.py +++ b/logistics_chat/quote_service.py @@ -55,6 +55,8 @@ def intent_with_context(message, conv_id, book): codes.update(value.upper() for rate in book['rates'] for value in rate['destinations']) intent = parse_intent(message,codes) earlier_intents=previous_quote_intents(conv_id,codes,resume=resumes_quote(message)) if is_followup(message,intent) else [] + if intent.get('quantity_change') and not earlier_intents: + intent['errors'].append(intent['quantity_question']) if intent.get('metal_weight_update') and not earlier_intents: intent['metal_chargeable_weight']=None intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。') @@ -83,15 +85,31 @@ def answer_quote(message, conv_id): return None intent = intent_with_context(message, conv_id, book) if intent['country'] != 'US': + if intent.get('quantity_change'): + if intent['quantity_change']['kind']=='volume': + return {'t':'请先提供原来的总体积和目的国家、目的仓,再说明增减量。'} + return {'t':'请先提供原来的总重、箱数和目的国家,再说明增减量。'} if resumes_quote(message): return {'t':'请确认要继续报价的目的国家、重量和渠道,我再核对对应条件。'} + if intent.get('mode')=='clarify_route': + return {'t':'请先提供目的国家、重量和所需渠道,再继续核对具体方案。'} if intent.get('weight_unit'): return {'t':'请提供重量数值和目的国家,例如800KG发往美国。'} if intent['country'] is None and intent.get('mode')=='preparation': return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'} return None + if intent.get('tax_pending') and '请明确选择包税或不包税方案。' not in intent['errors']: + intent['errors'].append('请明确选择包税或不包税方案。') + if intent.get('choice_pending') and '请指定希望使用的渠道,或选择最快、最慢、最便宜方案。' not in intent['errors']: + intent['errors'].append('请指定希望使用的渠道,或选择最快、最慢、最便宜方案。') if intent.get('errors'): return {'t':'\n'.join(intent['errors'])} + if intent.get('mode')=='clarify_route': + return {'t':'请指定方案名称,例如美森正班或普船,或说“最快”“最慢”“最便宜”,我按当前货物条件继续核算。'} + if intent.get('mode')=='difference': + if intent.get('selection') not in ('fastest','slowest','speed_compare'): + return {'t':'请明确要比较的方案,可直接问“最快和最慢相差多少”,我按当前货物条件核算差额。'} + intent['selection']='speed_compare' if intent.get('unknown_warehouses'): return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'} if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or diff --git a/tests/test_order_statistics.py b/tests/test_order_statistics.py index 5438045..deb94f4 100644 --- a/tests/test_order_statistics.py +++ b/tests/test_order_statistics.py @@ -263,6 +263,90 @@ class OrderStatisticsTests(unittest.TestCase): self.ask('我有多少异常订单',cid) self.assertEqual(self.ask('第一个呢',cid)['card']['no'],'STRUCTURED20260001') + def test_adjacent_order_navigation_keeps_the_displayed_rows_and_current_selection(self): + self.login();cid=self.client.post('/api/new_chat').json['id'] + text=self.ask('我有多少异常订单',cid)['t'] + shown=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M) + self.assertEqual(self.ask('第一个呢',cid)['card']['no'],shown[0][1]) + self.assertEqual(self.ask('下一票呢',cid)['card']['no'],shown[1][1]) + self.ask('谢谢',cid) + self.assertEqual(self.ask('上一票的轨迹',cid)['card']['no'],shown[0][1]) + self.assertIn('未登记申报毛重',self.ask('那这个有多重',cid)['t']) + self.assertEqual(self.ask('下一票的轨迹',cid)['card']['no'],shown[1][1]) + + def test_adjacent_navigation_stops_at_visible_edges_and_resets_on_page_changes(self): + self.login();cid=self.client.post('/api/new_chat').json['id'] + with self.app.app_context(): + c=db.connect() + order_data.import_records(c,[{'tenant_id':'a','order_no':f'PAGEX2026{i:04}','inbound_code':f'PAGEIN2026{i:04}', + 'status':'PROCESSING'} for i in range(30)]) + c.commit();c.close() + self.ask('列出我的全部订单',cid) + self.ask('第20票的轨迹',cid) + self.assertIn('最后一票',self.ask('下一票呢',cid)['t']) + text=self.ask('下一页',cid)['t'] + first=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)[0][1] + self.assertEqual(self.ask('第一个呢',cid)['card']['no'],first) + self.assertIn('第一票',self.ask('上一票呢',cid)['t']) + + def test_adjacent_navigation_needs_an_owned_list_and_a_selected_row(self): + for question in ('下一票呢','上一票的轨迹'): + self.assertTrue(self.ask(question)['login_required']) + self.login();cid=self.client.post('/api/new_chat').json['id'] + self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t']) + self.ask('我有多少异常订单',cid) + self.assertIn('先在当前列表中选择',self.ask('下一票呢',cid)['t']) + self.ask('第一个呢',cid) + self.client.post('/api/customer/logout') + self.assertTrue(self.ask('下一票呢',cid)['login_required']) + + def test_navigation_and_weights_do_not_reuse_orders_after_a_quote_topic(self): + self.login();cid=self.client.post('/api/new_chat').json['id'] + self.ask('我有多少异常订单',cid) + self.ask('第一个呢',cid) + self.ask('美国30KG海运报价',cid) + self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t']) + + def test_implicit_count_without_an_established_range_asks_for_the_missing_scope(self): + self.login();cid=self.client.post('/api/new_chat').json['id'] + text=self.ask('异常的呢',cid)['t'] + self.assertIn('说明要统计的订单范围',text) + self.assertNotIn('7 票',text) + + def test_list_selection_keeps_the_order_when_its_old_inbound_code_is_reassigned(self): + self.login();cid=self.client.post('/api/new_chat').json['id'] + with self.app.app_context(): + c=db.connect() + order_data.import_records(c,[{'tenant_id':'a','order_no':'ORIGINAL20260001','inbound_code':'REUSEDINBOUND1', + 'status':'PROCESSING','source_updated_at':'2030-01-01','exception_reason':'原订单资料'}, + {'tenant_id':'a','order_no':'OTHER20260001','status':'PROCESSING', + 'source_updated_at':'2029-01-01','exception_reason':'另一票资料'}]) + c.commit();c.close() + self.ask('我有多少异常订单',cid) + with self.app.app_context(): + c=db.connect() + c.execute("UPDATE order_test_data SET inbound_code='NEWINBOUND1' WHERE tenant_id='a' AND order_no='ORIGINAL20260001'") + c.execute("UPDATE order_test_data SET inbound_code='REUSEDINBOUND1' WHERE tenant_id='a' AND order_no='OTHER20260001'") + c.commit();c.close() + card=self.ask('第一个呢',cid)['card'] + self.assertEqual(card['no'],'ORIGINAL20260001') + self.assertIn({'key':'inbound','value':'NEWINBOUND1'},card['fields']) + self.assertIn('原订单资料',self.ask('异常原因呢',cid)['t']) + self.assertEqual(self.ask('下一票呢',cid)['card']['no'],'OTHER20260001') + + def test_adjacent_selection_follows_actual_cards_after_overlapping_requests(self): + from logistics_chat.tracking import CARD_PREFIX + self.login();cid=self.client.post('/api/new_chat').json['id'] + self.ask('我有多少异常订单',cid) + second=self.ask('第二个呢',cid)['card'] + self.ask('第一个呢',cid) + with self.app.app_context(): + for _ in range(2): + db.save_message(cid,'user','下一票呢') + db.save_message(cid,'assistant',CARD_PREFIX+json.dumps(second,ensure_ascii=False)) + third=self.ask('下一票呢',cid)['card']['no'] + self.assertEqual(third,self.ask('第三个呢',cid)['card']['no']) + def test_ambiguous_speech_answers_the_clear_count_and_requests_the_missing_meaning(self): self.login() text=self.ask('当前,我的订单有多少条,一层有多少条?')['t'] diff --git a/tests/test_quote_changes.py b/tests/test_quote_changes.py new file mode 100644 index 0000000..100189a --- /dev/null +++ b/tests/test_quote_changes.py @@ -0,0 +1,214 @@ +"""Relative quantity and condition edits keep the previously confirmed quote basis.""" + +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from logistics_chat import create_app,db +from logistics_chat.quote_data import import_snapshot +from test_quote_timing import timing_snapshot +from test_quotes import frames + + +class QuoteChangeTests(unittest.TestCase): + def setUp(self): + folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup) + root=Path(folder.name) + self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads', + 'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000}) + self.client=self.app.test_client() + self.client.post('/api/visitor/session',json={'key':'5'*64}) + self.cid=self.client.post('/api/new_chat').json['id'] + with self.app.app_context(): + c=db.connect();import_snapshot(c,timing_snapshot(),activate=True);c.commit();c.close() + + 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.assertFalse(any(event.get('e') for event in events),events) + return events[0]['t'] + + def test_weight_increments_are_added_and_absolute_changes_replace_the_total(self): + self.ask('美国1000KG义乌美东普船海派报价') + self.assertIn('1100KG = 6600元',self.ask('再加100KG,其他不变')) + self.assertIn('900KG = 5400元',self.ask('再减掉200KG')) + self.assertIn('900KG = 5400元',self.ask('那一共多少钱')) + self.assertIn('800KG = 4800元',self.ask('减到800KG')) + self.assertIn('900KG = 5400元',self.ask('增加0.1吨,总重900KG')) + + def test_invalid_or_unitless_changes_stay_pending_until_a_new_total_is_confirmed(self): + self.ask('美国30KG义乌美西普船海派报价') + self.assertIn('调整后的总重需要大于0',self.ask('减去40KG')) + text=self.ask('换成美东') + self.assertIn('请提供调整后的总重',text) + self.assertNotIn('KG =',text) + self.assertIn('20KG = 120元',self.ask('总重20KG')) + self.assertIn('请提供调整后的总重',self.ask('再加100')) + self.assertNotIn('KG =',self.ask('具体多少')) + self.assertIn('50KG = 300元',self.ask('新总重50KG')) + + def test_relative_quantity_without_a_confirmed_starting_value_requests_a_total(self): + self.assertIn('原来的总重',self.ask('再加100KG')) + self.assertIn('调整后的总重',self.ask('美国普船海派')) + self.assertIn('200KG = 800元',self.ask('总重200KG,义乌美西')) + + def test_uniform_cartons_add_and_remove_without_losing_the_previous_cartons(self): + self.ask('美国10箱,每箱20KG,义乌美西普船海派报价') + self.assertIn('240KG = 960元',self.ask('再加2箱,每箱20KG')) + text=self.ask('减少1箱') + self.assertIn('220KG = 880元',text) + self.assertIn('共11箱',text) + self.assertIn('220KG = 880元',self.ask('那一共呢')) + + def test_added_carton_dimensions_apply_to_the_added_cartons_only(self): + self.ask('美国1箱,每箱20KG,30x30x30cm,义乌美西普船海派报价') + text=self.ask('再加1箱,每箱10KG,60x50x40cm') + self.assertIn('40KG = 160元',text) + self.assertNotIn('50KG =',text) + + def test_each_carton_minimum_remains_in_effect_after_addition(self): + self.ask('美国2箱,每箱5KG,义乌美西普船海派报价') + self.assertIn('36KG = 144元',self.ask('再加1箱,每箱5KG')) + + def test_declared_new_totals_are_validated_after_a_carton_change(self): + self.ask('美国10箱每箱20KG,义乌美西普船海派报价') + self.assertIn('240KG = 960元',self.ask('再加2箱每箱20KG,总重240KG')) + self.assertIn('增减箱数推算的总重',self.ask('再加1箱每箱20KG,总重300KG')) + self.assertNotIn('KG =',self.ask('换成华南')) + self.assertIn('300KG',self.ask('总重300KG,共13箱')) + + def test_heterogeneous_carton_removal_requires_the_updated_breakdown(self): + self.ask('美国1箱10KG,1箱30KG,义乌美西普船海派报价') + self.assertIn('不同箱规',self.ask('减少1箱')) + self.assertNotIn('KG =',self.ask('具体多少钱')) + self.assertIn('30KG = 120元',self.ask('现在1箱30KG')) + + def test_rejected_services_remain_excluded_until_explicitly_selected_again(self): + self.ask('美国1000KG义乌美东海运最便宜报价') + text=self.ask('不走普船了,改成美森正班') + self.assertIn('1000KG = 10000元',text) + self.assertNotIn('普船海派',text) + text=self.ask('不要美森,走普船') + self.assertIn('1000KG = 6000元',text) + self.assertNotIn('美森',text) + self.ask('不走普船') + text=self.ask('改成华南') + self.assertNotIn('普船海派',text) + self.assertNotIn('美森',text) + self.assertIn('合德海派',text) + self.assertIn('普船海派',self.ask('那还是普船吧')) + self.assertIn('美森正班海派',self.ask('全部渠道报价')) + + def test_tax_corrections_and_pending_conflicts_are_not_reversed(self): + self.ask('美国1000KG义乌美东海派报价') + self.assertIn('1000KG = 6000元',self.ask('不要不包税,我要包税')) + self.assertIn('没有匹配',self.ask('不是包税,是不包税')) + self.assertIn('1000KG = 6000元',self.ask('那还是包税吧')) + self.assertIn('1000KG = 6000元',self.ask('不是包税吗?')) + self.assertIn('明确选择包税',self.ask('不要包税,也不要不包税')) + self.assertNotIn('KG =',self.ask('换成华南')) + self.assertIn('1000KG = 6000元',self.ask('要包税')) + + def test_new_batch_with_rejections_resets_old_shipping_conditions(self): + self.ask('美国1000KG义乌美东普船海派报价') + text=self.ask('新的一批美国30KG海派报价,不走普船') + self.assertIn('美国 30KG',text) + self.assertIn('华南',text) + self.assertIn('美西',text) + self.assertNotIn('普船海派',text) + self.assertNotIn('1000KG',text) + + def test_calendar_questions_preserve_route_and_request_the_missing_dates(self): + self.ask('美国1000KG义乌美东普船海派报价') + for question in ('什么时候能到','大概哪天能收到'): + text=self.ask(question) + self.assertIn('25-30天左右提取',text) + self.assertIn('预计开船日期',text) + text=self.ask('快一点呢') + self.assertIn('美森正班海派',text) + self.assertNotIn('普船海派',text) + text=self.ask('那比慢的贵多少') + self.assertIn('运费差额为4000元',text) + + def test_ambiguous_plan_reference_keeps_context_for_a_named_selection(self): + self.ask('美国1000KG海运最快和最慢报价') + self.assertIn('1000KG',self.ask('这个多少钱')) + self.assertIn('请指定方案名称',self.ask('第二个方案呢')) + self.assertIn('1000KG = 4000元',self.ask('便宜的那个')) + self.assertIn('1000KG = 4000元',self.ask('一共呢')) + + def test_tied_speed_costs_produce_a_range_without_mixing_tax_or_units(self): + from logistics_chat.quote_comparison import speed_difference_lines + from logistics_chat.quote_data import load_book + from logistics_chat.quote_intent import parse_intent + base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'} + rates=[{**base,'service':name,'price':price,'eta':eta} for name,price,eta in ( + ('快线A','8','12-15天'),('快线B','10','12-15天'), + ('慢线A','5','30-35天'),('慢线B','6','30-35天'))] + rates.append({**base,'service':'自税线','tax':'excluded','price':'999','eta':'20天'}) + rates.append({**base,'service':'按方线','unit':'cbm','price':'999','eta':'20天'}) + with self.app.app_context(): + book=load_book() + intent=parse_intent('美国1000KG义乌美东海派报价',set()) + lines=speed_difference_lines(intent,book,rates,None) + self.assertTrue(any(' · 包税 · ' in line and '2000至5000元' in line for line in lines),lines) + self.assertTrue(any('不包税' in line and '差额为0元' in line for line in lines),lines) + self.assertEqual(len(lines),2) + self.assertEqual(speed_difference_lines(intent,book,[{**rates[0],'eta':'未提供时效'}],None),[]) + + def test_polite_relative_edits_and_unrecognized_adjustments_do_not_replace_the_total(self): + self.ask('美国1000KG义乌美东普船海派报价') + self.assertIn('1100KG = 6600元',self.ask('那我再加一百公斤')) + self.assertIn('900KG = 5400元',self.ask('请帮我减少200KG')) + self.assertIn('调整后的总重',self.ask('这批货后面增加100KG')) + self.assertNotIn('KG =',self.ask('具体多少钱')) + + def test_difference_uses_the_displayed_two_decimal_costs(self): + from logistics_chat.quote_comparison import speed_difference_lines + from logistics_chat.quote_data import load_book + from logistics_chat.quote_intent import parse_intent + base={'origin':'义乌','zone':'美东','kind':'parcel','unit':'kg','tax':'included'} + rates=[{**base,'service':'快线','price':'6.4','eta':'12天'}, + {**base,'service':'慢线','price':'4.3','eta':'30天'}] + with self.app.app_context(): + intent=parse_intent('美国12.555KG义乌美东海派报价',set()) + text='\n'.join(speed_difference_lines(intent,load_book(),rates,None)) + self.assertIn('差额为26.36元',text) + + def test_difference_shows_both_speeds_for_each_origin_used_in_the_calculation(self): + with self.app.app_context(): + c=db.connect() + c.execute("UPDATE quote_rates SET eta='船开后16天左右' WHERE service='美森正班海派' AND origin='华南'") + c.commit();c.close() + text=self.ask('美国1000KG美东海派最快最慢差价') + self.assertIn('美森正班海派 · 义乌',text) + self.assertIn('美森正班海派 · 华南',text) + self.assertEqual(text.count('运费差额为4000元'),2) + + def test_volume_increments_keep_the_confirmed_total_and_units(self): + self.ask('美国2CBM义乌美东普船海派报价') + text=self.ask('再加1方') + self.assertIn('总体积为3CBM',text) + self.assertIn('3000元',text) + text=self.ask('总体积减少0.5CBM') + self.assertIn('总体积为2.5CBM',text) + self.assertIn('2500元',text) + + def test_carton_changes_with_an_aggregate_volume_wait_for_the_updated_volume(self): + self.ask('美国2箱每箱20KG,1CBM,义乌美东普船海派报价') + self.assertIn('调整后的总体积',self.ask('再加1箱每箱20KG')) + text=self.ask('总重60KG') + self.assertIn('调整后的总体积',text) + self.assertNotIn('KG =',text) + text=self.ask('总体积1.5CBM') + self.assertIn('1500元',text) + + def test_unrecognized_volume_increment_is_clarified_without_using_it_as_a_new_total(self): + self.ask('美国2CBM义乌美东普船海派报价') + text=self.ask('这批货后面增加1方') + self.assertIn('调整后的总体积',text) + self.assertNotIn('元/KG',text) + self.assertIn('3000元',self.ask('总体积3CBM'))